From 1cc9e145d8afc50d778d664e06e88641cd23b918 Mon Sep 17 00:00:00 2001 From: Michael Smith Date: Tue, 28 Oct 2025 11:26:08 -0700 Subject: [PATCH 1/3] fix: Uses the npm-profile package to create tokens with GAT support --- lib/commands/token.js | 94 ++++- .../test/lib/commands/config.js.test.cjs | 21 ++ tap-snapshots/test/lib/docs.js.test.cjs | 176 +++++++++- test/lib/commands/token.js | 329 +++++++++++++++--- .../config/lib/definitions/definitions.js | 110 ++++++ 5 files changed, 671 insertions(+), 59 deletions(-) diff --git a/lib/commands/token.js b/lib/commands/token.js index c99221100658d..1b646d30e2d61 100644 --- a/lib/commands/token.js +++ b/lib/commands/token.js @@ -1,5 +1,5 @@ const { log, output, META } = require('proc-log') -const { listTokens, createToken, removeToken } = require('npm-profile') +const { listTokens, createGatToken, removeToken } = require('npm-profile') const { otplease } = require('../utils/auth.js') const readUserInfo = require('../utils/read-user-info.js') const BaseCommand = require('../base-cmd.js') @@ -7,8 +7,23 @@ const BaseCommand = require('../base-cmd.js') class Token extends BaseCommand { static description = 'Manage your authentication tokens' static name = 'token' - static usage = ['list', 'revoke ', 'create [--read-only] [--cidr=list]'] - static params = ['read-only', 'cidr', 'registry', 'otp'] + static usage = ['list', 'revoke ', 'create --name= [--token-description=] [--packages=] [--packages-all] [--scopes=] [--orgs=] [--packages-and-scopes-permission=] [--orgs-permission=] [--expires=] [--cidr=] [--bypass-2fa] [--password=]'] + static params = ['name', + 'token-description', + 'expires', + 'packages', + 'packages-all', + 'scopes', + 'orgs', + 'packages-and-scopes-permission', + 'orgs-permission', + 'cidr', + 'bypass-2fa', + 'password', + 'registry', + 'otp', + 'read-only', + ] static async completion (opts) { const argv = opts.conf.argv.remain @@ -127,15 +142,72 @@ class Token extends BaseCommand { const json = this.npm.config.get('json') const parseable = this.npm.config.get('parseable') const cidr = this.npm.config.get('cidr') - const readonly = this.npm.config.get('read-only') + const name = this.npm.config.get('name') + const tokenDescription = this.npm.config.get('token-description') + const expires = this.npm.config.get('expires') + const packages = this.npm.config.get('packages') + const packagesAll = this.npm.config.get('packages-all') + const scopes = this.npm.config.get('scopes') + const orgs = this.npm.config.get('orgs') + const packagesAndScopesPermission = this.npm.config.get('packages-and-scopes-permission') + const orgsPermission = this.npm.config.get('orgs-permission') + const bypassTwoFactor = this.npm.config.get('bypass-2fa') + let password = this.npm.config.get('password') const validCIDR = await this.validateCIDRList(cidr) - const password = await readUserInfo.password() + + /* istanbul ignore if - skip testing read input */ + if (!password) { + password = await readUserInfo.password() + } + + const tokenData = { + name: name, + password: password, + } + + if (tokenDescription) { + tokenData.description = tokenDescription + } + + if (packages?.length > 0) { + tokenData.packages = packages + } + if (packagesAll) { + tokenData.packages_all = true + } + if (scopes?.length > 0) { + tokenData.scopes = scopes + } + if (orgs?.length > 0) { + tokenData.orgs = orgs + } + + if (packagesAndScopesPermission) { + tokenData.packages_and_scopes_permission = packagesAndScopesPermission + } + if (orgsPermission) { + tokenData.orgs_permission = orgsPermission + } + + // Add expiration in days + if (expires) { + tokenData.expires = parseInt(expires, 10) + } + + // Add optional fields + if (validCIDR?.length > 0) { + tokenData.cidr_whitelist = validCIDR + } + if (bypassTwoFactor) { + tokenData.bypass_2fa = true + } + log.info('token', 'creating') const result = await otplease( this.npm, { ...this.npm.flatOptions }, - c => createToken(password, readonly, validCIDR, c) + c => createGatToken(tokenData, c) ) delete result.key delete result.updated @@ -145,12 +217,16 @@ class Token extends BaseCommand { Object.keys(result).forEach(k => output.standard(k + '\t' + result[k])) } else { const chalk = this.npm.chalk - // Identical to list - const level = result.readonly ? 'read only' : 'publish' + // Display based on access level + // Identical to list? XXX + const level = result.access === 'read-only' || result.readonly ? 'read only' : 'publish' output.standard(`Created ${chalk.blue(level)} token ${result.token}`, { [META]: true, redact: false }) if (result.cidr_whitelist?.length) { output.standard(`with IP whitelist: ${chalk.green(result.cidr_whitelist.join(','))}`) } + if (result.expires) { + output.standard(`expires: ${result.expires}`) + } } } @@ -180,7 +256,7 @@ class Token extends BaseCommand { for (const cidr of list) { if (isCidrV6(cidr)) { throw this.invalidCIDRError( - `CIDR whitelist can only contain IPv4 addresses${cidr} is IPv6` + `CIDR whitelist can only contain IPv4 addresses, ${cidr} is IPv6` ) } diff --git a/tap-snapshots/test/lib/commands/config.js.test.cjs b/tap-snapshots/test/lib/commands/config.js.test.cjs index bc0f406166a9f..ca6bf25c81fb0 100644 --- a/tap-snapshots/test/lib/commands/config.js.test.cjs +++ b/tap-snapshots/test/lib/commands/config.js.test.cjs @@ -23,6 +23,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "before": null, "bin-links": true, "browser": null, + "bypass-2fa": false, "ca": null, "cache-max": null, "cache-min": 0, @@ -48,6 +49,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "engine-strict": false, "expect-result-count": null, "expect-results": null, + "expires": null, "fetch-retries": 2, "fetch-retry-factor": 10, "fetch-retry-maxtimeout": 60000, @@ -97,6 +99,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "logs-dir": null, "logs-max": 10, "long": false, + "name": null, "maxsockets": 15, "message": "%s", "node-gyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js", @@ -108,6 +111,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "omit": [], "omit-lockfile-registry-resolved": false, "only": null, + "orgs": null, "optional": null, "os": null, "otp": null, @@ -115,6 +119,7 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "package-lock": true, "package-lock-only": false, "pack-destination": ".", + "packages": [], "parseable": false, "prefer-dedupe": false, "prefer-offline": false, @@ -141,6 +146,11 @@ exports[`test/lib/commands/config.js TAP config list --json > output matches sna "sbom-format": null, "sbom-type": "library", "scope": "", + "scopes": null, + "packages-all": false, + "packages-and-scopes-permission": null, + "orgs-permission": null, + "token-description": null, "script-shell": null, "searchexclude": "", "searchlimit": 20, @@ -187,6 +197,7 @@ auth-type = "web" before = null bin-links = true browser = null +bypass-2fa = false ca = null ; cache = "{CACHE}" ; overridden by cli cache-max = null @@ -214,6 +225,7 @@ editor = "{EDITOR}" engine-strict = false expect-result-count = null expect-results = null +expires = null fetch-retries = 2 fetch-retry-factor = 10 fetch-retry-maxtimeout = 60000 @@ -266,6 +278,7 @@ logs-max = 10 ; long = false ; overridden by cli maxsockets = 15 message = "%s" +name = null node-gyp = "{CWD}/node_modules/node-gyp/bin/node-gyp.js" node-options = null noproxy = [""] @@ -275,13 +288,19 @@ omit = [] omit-lockfile-registry-resolved = false only = null optional = null +orgs = null +orgs-permission = null os = null otp = null pack-destination = "." package = [] package-lock = true package-lock-only = false +packages = [] +packages-all = false +packages-and-scopes-permission = null parseable = false +password = (protected) prefer-dedupe = false prefer-offline = false prefer-online = false @@ -307,6 +326,7 @@ save-prod = false sbom-format = null sbom-type = "library" scope = "" +scopes = null script-shell = null searchexclude = "" searchlimit = 20 @@ -321,6 +341,7 @@ strict-ssl = true tag = "latest" tag-version-prefix = "v" timing = false +token-description = null umask = 0 unicode = false update-notifier = true diff --git a/tap-snapshots/test/lib/docs.js.test.cjs b/tap-snapshots/test/lib/docs.js.test.cjs index 0df150458de78..1e152fc8954fe 100644 --- a/tap-snapshots/test/lib/docs.js.test.cjs +++ b/tap-snapshots/test/lib/docs.js.test.cjs @@ -302,6 +302,17 @@ Set to \`true\` to use default system URL opener. +#### \`bypass-2fa\` + +* Default: false +* Type: Boolean + +When creating a Granular Access Token with \`npm token create\`, setting this +to true will allow the token to bypass two-factor authentication. This is +useful for automation and CI/CD workflows. + + + #### \`ca\` * Default: null @@ -556,6 +567,17 @@ true (expect some results) or false (expect no results). This config cannot be used with: \`expect-result-count\` +#### \`expires\` + +* Default: null +* Type: null or Number + +When creating a Granular Access Token with \`npm token create\`, this sets the +expiration in days. If not specified, the server will determine the default +expiration. + + + #### \`fetch-retries\` * Default: 2 @@ -1081,6 +1103,16 @@ Any "%s" in the message will be replaced with the version number. +#### \`name\` + +* Default: null +* Type: null or String + +When creating a Granular Access Token with \`npm token create\`, this sets the +name/description for the token. + + + #### \`node-gyp\` * Default: The path to the node-gyp bin that ships with npm @@ -1158,6 +1190,28 @@ time. +#### \`orgs\` + +* Default: null +* Type: null or String (can be set multiple times) + +When creating a Granular Access Token with \`npm token create\`, this limits +the token access to specific organizations. Provide a comma-separated list +of organization names. + + + +#### \`orgs-permission\` + +* Default: null +* Type: null, "read-only", "read-write", or "no-access" + +When creating a Granular Access Token with \`npm token create\`, sets the +permission level for organizations. Options are "read-only", "read-write", +or "no-access". + + + #### \`os\` * Default: null @@ -1225,6 +1279,38 @@ For \`list\` this means the output will be based on the tree described by the +#### \`packages\` + +* Default: +* Type: null or String (can be set multiple times) + +When creating a Granular Access Token with \`npm token create\`, this limits +the token access to specific packages. Provide a comma-separated list of +package names. + + + +#### \`packages-all\` + +* Default: false +* Type: Boolean + +When creating a Granular Access Token with \`npm token create\`, grants the +token access to all packages instead of limiting to specific packages. + + + +#### \`packages-and-scopes-permission\` + +* Default: null +* Type: null, "read-only", "read-write", or "no-access" + +When creating a Granular Access Token with \`npm token create\`, sets the +permission level for packages and scopes. Options are "read-only", +"read-write", or "no-access". + + + #### \`parseable\` * Default: false @@ -1235,6 +1321,16 @@ Output parseable results from commands that write to standard output. For +#### \`password\` + +* Default: null +* Type: null or String + +Password for authentication. Can be provided via command line when creating +tokens, though it's generally safer to be prompted for it. + + + #### \`prefer-dedupe\` * Default: false @@ -1520,6 +1616,17 @@ npm init --scope=@foo --yes +#### \`scopes\` + +* Default: null +* Type: null or String (can be set multiple times) + +When creating a Granular Access Token with \`npm token create\`, this limits +the token access to specific scopes. Provide a comma-separated list of scope +names (with or without @ prefix). + + + #### \`script-shell\` * Default: '/bin/sh' on POSIX systems, 'cmd.exe' on Windows @@ -1687,6 +1794,15 @@ while still writing the timing file, use \`--silent\`. +#### \`token-description\` + +* Default: null +* Type: null or String + +Description text for the token when using \`npm token create\`. + + + #### \`umask\` * Default: 0 @@ -2103,6 +2219,7 @@ Array [ "before", "bin-links", "browser", + "bypass-2fa", "ca", "cache", "cache-max", @@ -2130,6 +2247,7 @@ Array [ "engine-strict", "expect-result-count", "expect-results", + "expires", "fetch-retries", "fetch-retry-factor", "fetch-retry-maxtimeout", @@ -2180,6 +2298,7 @@ Array [ "logs-dir", "logs-max", "long", + "name", "maxsockets", "message", "node-gyp", @@ -2189,6 +2308,7 @@ Array [ "omit", "omit-lockfile-registry-resolved", "only", + "orgs", "optional", "os", "otp", @@ -2196,6 +2316,7 @@ Array [ "package-lock", "package-lock-only", "pack-destination", + "packages", "parseable", "prefer-dedupe", "prefer-offline", @@ -2222,6 +2343,12 @@ Array [ "sbom-format", "sbom-type", "scope", + "scopes", + "packages-all", + "packages-and-scopes-permission", + "orgs-permission", + "password", + "token-description", "script-shell", "searchexclude", "searchlimit", @@ -2266,6 +2393,7 @@ Array [ "before", "bin-links", "browser", + "bypass-2fa", "ca", "cache", "cache-max", @@ -2291,6 +2419,7 @@ Array [ "dry-run", "editor", "engine-strict", + "expires", "fetch-retries", "fetch-retry-factor", "fetch-retry-maxtimeout", @@ -2324,6 +2453,7 @@ Array [ "location", "lockfile-version", "loglevel", + "name", "maxsockets", "message", "node-gyp", @@ -2332,6 +2462,7 @@ Array [ "omit", "omit-lockfile-registry-resolved", "only", + "orgs", "optional", "os", "otp", @@ -2339,6 +2470,7 @@ Array [ "package-lock", "package-lock-only", "pack-destination", + "packages", "parseable", "prefer-dedupe", "prefer-offline", @@ -2364,6 +2496,12 @@ Array [ "sbom-format", "sbom-type", "scope", + "scopes", + "packages-all", + "packages-and-scopes-permission", + "orgs-permission", + "password", + "token-description", "script-shell", "searchexclude", "searchlimit", @@ -2433,6 +2571,7 @@ Object { "before": null, "binLinks": true, "browser": null, + "bypass-2fa": false, "ca": null, "cache": "{CWD}/cache/_cacache", "call": "", @@ -2454,6 +2593,7 @@ Object { "dryRun": false, "editor": "{EDITOR}", "engineStrict": false, + "expires": null, "force": false, "foregroundScripts": false, "formatPackageLock": true, @@ -2481,6 +2621,7 @@ Object { "logColor": false, "maxSockets": 15, "message": "%s", + "name": null, "nodeBin": "{NODE}", "nodeGyp": "{CWD}/node_modules/node-gyp/bin/node-gyp.js", "nodeVersion": "2.2.2", @@ -2492,13 +2633,19 @@ Object { "offline": false, "omit": Array [], "omitLockfileRegistryResolved": false, + "orgs": null, + "orgsPermission": null, "os": null, "otp": null, "package": Array [], "packageLock": true, "packageLockOnly": false, + "packages": Array [], + "packagesAll": false, + "packagesAndScopesPermission": null, "packDestination": ".", "parseable": false, + "password": null, "preferDedupe": false, "preferOffline": false, "preferOnline": false, @@ -2524,6 +2671,7 @@ Object { "sbomFormat": null, "sbomType": "library", "scope": "", + "scopes": null, "scriptShell": undefined, "search": Object { "description": true, @@ -2540,6 +2688,7 @@ Object { "strictSSL": true, "tagVersionPrefix": "v", "timeout": 300000, + "tokenDescription": null, "tufCache": "{CWD}/cache/_tuf", "umask": 0, "unicode": false, @@ -4308,26 +4457,43 @@ Manage your authentication tokens Usage: npm token list npm token revoke -npm token create [--read-only] [--cidr=list] +npm token create --name= [--token-description=] [--packages=] [--packages-all] [--scopes=] [--orgs=] [--packages-and-scopes-permission=] [--orgs-permission=] [--expires=] [--cidr=] [--bypass-2fa] [--password=] Options: -[--read-only] [--cidr [--cidr ...]] [--registry ] -[--otp ] +[--name ] [--token-description ] [--expires ] +[--packages [--packages ...]] [--packages-all] +[--scopes <@scope1,@scope2> [--scopes <@scope1,@scope2> ...]] +[--orgs [--orgs ...]] +[--packages-and-scopes-permission ] +[--orgs-permission ] +[--cidr [--cidr ...]] [--bypass-2fa] [--password ] +[--registry ] [--otp ] [--read-only] Run "npm help token" for more info \`\`\`bash npm token list npm token revoke -npm token create [--read-only] [--cidr=list] +npm token create --name= [--token-description=] [--packages=] [--packages-all] [--scopes=] [--orgs=] [--packages-and-scopes-permission=] [--orgs-permission=] [--expires=] [--cidr=] [--bypass-2fa] [--password=] \`\`\` Note: This command is unaware of workspaces. -#### \`read-only\` +#### \`name\` +#### \`token-description\` +#### \`expires\` +#### \`packages\` +#### \`packages-all\` +#### \`scopes\` +#### \`orgs\` +#### \`packages-and-scopes-permission\` +#### \`orgs-permission\` #### \`cidr\` +#### \`bypass-2fa\` +#### \`password\` #### \`registry\` #### \`otp\` +#### \`read-only\` ` exports[`test/lib/docs.js TAP usage undeprecate > must match snapshot 1`] = ` diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index f60a938b5b34b..9e58002767788 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -1,11 +1,8 @@ const t = require('tap') const { load: loadMockNpm } = require('../../fixtures/mock-npm.js') const MockRegistry = require('@npmcli/mock-registry') -const mockGlobals = require('@npmcli/mock-globals') -const stream = require('node:stream') const authToken = 'abcd1234' -const password = 'this is not really a password' const auth = { '//registry.npmjs.org/:_authToken': authToken, @@ -249,6 +246,8 @@ t.test('token create', async t => { config: { ...auth, cidr, + name: 'test-token', + password: 'test-password', }, }) const registry = new MockRegistry({ @@ -256,16 +255,19 @@ t.test('token create', async t => { registry: npm.config.get('registry'), authorization: authToken, }) - const stdin = new stream.PassThrough() - stdin.write(`${password}\n`) - mockGlobals(t, { - 'process.stdin': stdin, - 'process.stdout': new stream.PassThrough(), // to quiet readline - }, { replace: true }) - registry.createToken({ password, cidr }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'test-token' && + body.password === 'test-password' && + body.cidr_whitelist.length === 2 && + body.token_description === undefined + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + cidr_whitelist: cidr, + created: new Date().toISOString(), + }) await npm.exec('token', ['create']) - t.strictSame(outputs, [ - '', + t.match(outputs, [ 'Created publish token n3wt0k3n', 'with IP whitelist: 10.0.0.0/8,192.168.1.0/24', ]) @@ -275,7 +277,8 @@ t.test('token create read only', async t => { const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, - 'read-only': true, + name: 'readonly-token', + password: 'test-password', }, }) const registry = new MockRegistry({ @@ -283,20 +286,244 @@ t.test('token create read only', async t => { registry: npm.config.get('registry'), authorization: authToken, }) - const stdin = new stream.PassThrough() - stdin.write(`${password}\n`) - mockGlobals(t, { - 'process.stdin': stdin, - 'process.stdout': new stream.PassThrough(), // to quiet readline - }, { replace: true }) - registry.createToken({ readonly: true, password }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'readonly-token' && + body.password === 'test-password' && + body.token_description === undefined + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-only', + created: new Date().toISOString(), + }) await npm.exec('token', ['create']) - t.strictSame(outputs, [ - '', + t.match(outputs, [ 'Created read only token n3wt0k3n', ]) }) +t.test('token create with expiry', async t => { + const expires = 30 + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'expiry-token', + password: 'test-password', + expires, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'expiry-token' && + body.password === 'test-password' && + body.expires === 30 + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-only', + created: new Date().toISOString(), + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created read only token n3wt0k3n', + ]) + t.match(outputs.join('\n'), /expires:/) +}) + +t.test('token create with description', async t => { + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'description-token', + password: 'test-password', + 'token-description': 'My custom token for CI/CD', + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'description-token' && + body.password === 'test-password' && + body.token_description === 'My custom token for CI/CD' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + +t.test('token create with packages', async t => { + const packages = ['@scope/pkg1', 'pkg2'] + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'packages-token', + password: 'test-password', + packages, + 'packages-and-scopes-permission': 'read-write', + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'packages-token' && + body.password === 'test-password' && + body.packages.length === 2 && + body.packages[0] === '@scope/pkg1' && + body.packages[1] === 'pkg2' && + body.packages_and_scopes_permission === 'read-write' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + +t.test('token create with packages-all', async t => { + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'all-packages-token', + password: 'test-password', + 'packages-all': true, + 'packages-and-scopes-permission': 'read-write', + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'all-packages-token' && + body.password === 'test-password' && + body.packages_all === true && + body.packages_and_scopes_permission === 'read-write' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + +t.test('token create with scopes', async t => { + const scopes = ['@scope1', '@scope2'] + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'scopes-token', + password: 'test-password', + scopes, + 'packages-and-scopes-permission': 'read-write', + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'scopes-token' && + body.password === 'test-password' && + body.scopes.length === 2 && + body.scopes[0] === '@scope1' && + body.scopes[1] === '@scope2' && + body.packages_and_scopes_permission === 'read-write' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + +t.test('token create with orgs', async t => { + const orgs = ['org1', 'org2'] + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'orgs-token', + password: 'test-password', + orgs, + 'orgs-permission': 'read-write', + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'orgs-token' && + body.password === 'test-password' && + body.orgs.length === 2 && + body.orgs[0] === 'org1' && + body.orgs[1] === 'org2' && + body.orgs_permission === 'read-write' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + +t.test('token create with bypass-2fa', async t => { + const { npm, outputs } = await loadMockNpm(t, { + config: { + ...auth, + name: 'bypass2fa-token', + password: 'test-password', + 'bypass-2fa': true, + }, + }) + const registry = new MockRegistry({ + tap: t, + registry: npm.config.get('registry'), + authorization: authToken, + }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'bypass2fa-token' && + body.password === 'test-password' && + body.bypass_2fa === true + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + created: new Date().toISOString(), + }) + await npm.exec('token', ['create']) + t.match(outputs, [ + 'Created publish token n3wt0k3n', + ]) +}) + t.test('token create json output', async t => { const cidr = ['10.0.0.0/8', '192.168.1.0/24'] const { npm, joinedOutput } = await loadMockNpm(t, { @@ -304,6 +531,8 @@ t.test('token create json output', async t => { ...auth, json: true, cidr, + name: 'json-token', + password: 'test-password', }, }) const registry = new MockRegistry({ @@ -311,18 +540,20 @@ t.test('token create json output', async t => { registry: npm.config.get('registry'), authorization: authToken, }) - const stdin = new stream.PassThrough() - stdin.write(`${password}\n`) - mockGlobals(t, { - 'process.stdin': stdin, - 'process.stdout': new stream.PassThrough(), // to quiet readline - }, { replace: true }) - registry.createToken({ password, cidr }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'json-token' && + body.password === 'test-password' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + cidr_whitelist: cidr, + created: new Date().toISOString(), + }) await npm.exec('token', ['create']) const parsed = JSON.parse(joinedOutput()) t.match( parsed, - { token: 'n3wt0k3n', readonly: false, cidr_whitelist: cidr } + { token: 'n3wt0k3n', access: 'read-write', cidr_whitelist: cidr } ) t.ok(parsed.created, 'also returns created') }) @@ -334,6 +565,8 @@ t.test('token create parseable output', async t => { ...auth, parseable: true, cidr, + name: 'parseable-token', + password: 'test-password', }, }) const registry = new MockRegistry({ @@ -341,18 +574,20 @@ t.test('token create parseable output', async t => { registry: npm.config.get('registry'), authorization: authToken, }) - const stdin = new stream.PassThrough() - stdin.write(`${password}\n`) - mockGlobals(t, { - 'process.stdin': stdin, - 'process.stdout': new stream.PassThrough(), // to quiet readline - }, { replace: true }) - registry.createToken({ password, cidr }) + registry.nock.post('/-/npm/v1/tokens', body => { + return body.name === 'parseable-token' && + body.password === 'test-password' + }).reply(201, { + token: 'n3wt0k3n', + access: 'read-write', + cidr_whitelist: cidr, + created: new Date().toISOString(), + }) await npm.exec('token', ['create']) - t.equal(outputs[1], 'token\tn3wt0k3n') - t.ok(outputs[2].startsWith('created\t')) - t.equal(outputs[3], 'readonly\tfalse') - t.equal(outputs[4], 'cidr_whitelist\t10.0.0.0/8,192.168.1.0/24') + // In parseable mode, all fields are output as key\tvalue pairs + t.match(outputs.join('\n'), /token\tn3wt0k3n/) + t.match(outputs.join('\n'), /created\t/) + t.match(outputs.join('\n'), /cidr_whitelist\t10.0.0.0\/8,192.168.1.0\/24/) }) t.test('token create ipv6 cidr', async t => { @@ -360,12 +595,14 @@ t.test('token create ipv6 cidr', async t => { config: { ...auth, cidr: '::1/128', + name: 'ipv6-test', + access: 'read-only', }, }) - await t.rejects(npm.exec('token', ['create'], { + await t.rejects(npm.exec('token', ['create']), { code: 'EINVALIDCIDR', message: /CIDR whitelist can only contain IPv4 addresses, ::1\/128 is IPv6/, - })) + }) }) t.test('token create invalid cidr', async t => { @@ -373,10 +610,12 @@ t.test('token create invalid cidr', async t => { config: { ...auth, cidr: 'apple/cider', + name: 'invalid-cidr-test', + access: 'read-only', }, }) - await t.rejects(npm.exec('token', ['create'], { + await t.rejects(npm.exec('token', ['create']), { code: 'EINVALIDCIDR', message: 'CIDR whitelist contains invalid CIDR entry: apple/cider', - })) + }) }) diff --git a/workspaces/config/lib/definitions/definitions.js b/workspaces/config/lib/definitions/definitions.js index 4a35830b46a3c..570abecdb4484 100644 --- a/workspaces/config/lib/definitions/definitions.js +++ b/workspaces/config/lib/definitions/definitions.js @@ -274,6 +274,16 @@ const definitions = { `, flatten, }), + 'bypass-2fa': new Definition('bypass-2fa', { + default: false, + type: Boolean, + description: ` + When creating a Granular Access Token with \`npm token create\`, + setting this to true will allow the token to bypass two-factor + authentication. This is useful for automation and CI/CD workflows. + `, + flatten, + }), ca: new Definition('ca', { default: null, type: [null, String, Array], @@ -624,6 +634,16 @@ const definitions = { Can be either true (expect some results) or false (expect no results). `, }), + expires: new Definition('expires', { + default: null, + type: [null, Number], + description: ` + When creating a Granular Access Token with \`npm token create\`, + this sets the expiration in days. If not specified, the server + will determine the default expiration. + `, + flatten, + }), 'fetch-retries': new Definition('fetch-retries', { default: 2, type: Number, @@ -1281,6 +1301,16 @@ const definitions = { Show extended information in \`ls\`, \`search\`, and \`help-search\`. `, }), + name: new Definition('name', { + default: null, + type: [null, String], + hint: '', + description: ` + When creating a Granular Access Token with \`npm token create\`, + this sets the name/description for the token. + `, + flatten, + }), maxsockets: new Definition('maxsockets', { default: 15, type: Number, @@ -1409,6 +1439,17 @@ const definitions = { definitions.omit.flatten('omit', obj, flatOptions) }, }), + orgs: new Definition('orgs', { + default: null, + type: [null, String, Array], + hint: '', + description: ` + When creating a Granular Access Token with \`npm token create\`, + this limits the token access to specific organizations. Provide + a comma-separated list of organization names. + `, + flatten, + }), optional: new Definition('optional', { default: null, type: [null, Boolean], @@ -1505,6 +1546,17 @@ const definitions = { `, flatten, }), + packages: new Definition('packages', { + default: [], + type: [null, String, Array], + hint: '', + description: ` + When creating a Granular Access Token with \`npm token create\`, + this limits the token access to specific packages. Provide + a comma-separated list of package names. + `, + flatten, + }), parseable: new Definition('parseable', { default: false, type: Boolean, @@ -1900,6 +1952,64 @@ const definitions = { flatOptions.projectScope = scope }, }), + scopes: new Definition('scopes', { + default: null, + type: [null, String, Array], + hint: '<@scope1,@scope2>', + description: ` + When creating a Granular Access Token with \`npm token create\`, + this limits the token access to specific scopes. Provide + a comma-separated list of scope names (with or without @ prefix). + `, + flatten, + }), + 'packages-all': new Definition('packages-all', { + default: false, + type: Boolean, + description: ` + When creating a Granular Access Token with \`npm token create\`, + grants the token access to all packages instead of limiting to + specific packages. + `, + flatten, + }), + 'packages-and-scopes-permission': new Definition('packages-and-scopes-permission', { + default: null, + type: [null, 'read-only', 'read-write', 'no-access'], + description: ` + When creating a Granular Access Token with \`npm token create\`, + sets the permission level for packages and scopes. Options are + "read-only", "read-write", or "no-access". + `, + flatten, + }), + 'orgs-permission': new Definition('orgs-permission', { + default: null, + type: [null, 'read-only', 'read-write', 'no-access'], + description: ` + When creating a Granular Access Token with \`npm token create\`, + sets the permission level for organizations. Options are + "read-only", "read-write", or "no-access". + `, + flatten, + }), + password: new Definition('password', { + default: null, + type: [null, String], + description: ` + Password for authentication. Can be provided via command line when + creating tokens, though it's generally safer to be prompted for it. + `, + flatten, + }), + 'token-description': new Definition('token-description', { + default: null, + type: [null, String], + description: ` + Description text for the token when using \`npm token create\`. + `, + flatten, + }), 'script-shell': new Definition('script-shell', { default: null, defaultDescription: ` From 709425b12c7d94027cdce52c8310729ed0a88188 Mon Sep 17 00:00:00 2001 From: Gar Date: Thu, 13 Nov 2025 09:28:30 -0800 Subject: [PATCH 2/3] fixup: use npm-registry-fetch instead clean up tests too, combine all the extras into one test. --- lib/commands/token.js | 44 +++-- mock-registry/lib/index.js | 35 ++-- test/lib/commands/token.js | 364 +++++++++++-------------------------- 3 files changed, 159 insertions(+), 284 deletions(-) diff --git a/lib/commands/token.js b/lib/commands/token.js index 1b646d30e2d61..9cc04f83e7dd2 100644 --- a/lib/commands/token.js +++ b/lib/commands/token.js @@ -1,9 +1,18 @@ const { log, output, META } = require('proc-log') -const { listTokens, createGatToken, removeToken } = require('npm-profile') +const fetch = require('npm-registry-fetch') const { otplease } = require('../utils/auth.js') const readUserInfo = require('../utils/read-user-info.js') const BaseCommand = require('../base-cmd.js') +async function paginate (href, opts, items = []) { + while (href) { + const result = await fetch.json(href, opts) + items = items.concat(result.objects) + href = result.urls.next + } + return items +} + class Token extends BaseCommand { static description = 'Manage your authentication tokens' static name = 'token' @@ -63,7 +72,7 @@ class Token extends BaseCommand { const json = this.npm.config.get('json') const parseable = this.npm.config.get('parseable') log.info('token', 'getting list') - const tokens = await listTokens(this.npm.flatOptions) + const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions) if (json) { output.buffer(tokens) return @@ -104,10 +113,9 @@ class Token extends BaseCommand { const json = this.npm.config.get('json') const parseable = this.npm.config.get('parseable') const toRemove = [] - const opts = { ...this.npm.flatOptions } log.info('token', `removing ${toRemove.length} tokens`) - const tokens = await listTokens(opts) - args.forEach(id => { + const tokens = await paginate('/-/npm/v1/tokens', this.npm.flatOptions) + for (const id of args) { const matches = tokens.filter(token => token.key.indexOf(id) === 0) if (matches.length === 1) { toRemove.push(matches[0].key) @@ -123,12 +131,16 @@ class Token extends BaseCommand { toRemove.push(id) } - }) - await Promise.all( - toRemove.map(key => { - return otplease(this.npm, opts, c => removeToken(key, c)) - }) - ) + } + for (const tokenKey of toRemove) { + await otplease(this.npm, this.npm.flatOptions, opts => + fetch(`/-/npm/v1/tokens/token/${tokenKey}`, { + ...opts, + method: 'DELETE', + ignoreBody: true, + }) + ) + } if (json) { output.buffer(toRemove) } else if (parseable) { @@ -204,10 +216,12 @@ class Token extends BaseCommand { } log.info('token', 'creating') - const result = await otplease( - this.npm, - { ...this.npm.flatOptions }, - c => createGatToken(tokenData, c) + const result = await otplease(this.npm, this.npm.flatOptions, opts => + fetch.json('/-/npm/v1/tokens', { + ...opts, + method: 'POST', + body: tokenData, + }) ) delete result.key delete result.updated diff --git a/mock-registry/lib/index.js b/mock-registry/lib/index.js index d8dbdcdcce10a..9b14cd46d8937 100644 --- a/mock-registry/lib/index.js +++ b/mock-registry/lib/index.js @@ -442,7 +442,7 @@ class MockRegistry { } getTokens (tokens) { - return this.nock.get('/-/npm/v1/tokens') + return this.nock.get(this.fullPath('/-/npm/v1/tokens')) .reply(200, { objects: tokens, urls: {}, @@ -451,19 +451,26 @@ class MockRegistry { }) } - createToken ({ password, readonly = false, cidr = [] }) { - return this.nock.post('/-/npm/v1/tokens', { - password, - readonly, - cidr_whitelist: cidr, - }).reply(200, { - key: 'n3wk3y', - token: 'n3wt0k3n', - created: new Date(), - updated: new Date(), - readonly, - cidr_whitelist: cidr, - }) + // The server has rules for what resultData correlates with what tokenData but we don't need to be 100% in sync with that, we just need to be able to pass all of the possible tokenData attributes, and be able to accept all of the possible resultData attributes + createToken (tokenData, resultData = {}) { + return this.nock.post(this.fullPath('/-/npm/v1/tokens'), tokenData) + .reply(201, { + id: `0xdeadbeef`, + key: 'n3wk3y', + token: 'n3wt0k3n', + created: new Date(), + updated: new Date(), + access: 'read-only', + name: tokenData.name, + password: tokenData.password, + ...resultData, + }) + } + + revokeToken (token) { + return this.nock.delete( + this.fullPath(`/-/npm/v1/tokens/token/${token}`) + ).reply(200) } async package ({ manifest, times = 1, query, tarballs }) { diff --git a/test/lib/commands/token.js b/test/lib/commands/token.js index 9e58002767788..76ffc4f7da80e 100644 --- a/test/lib/commands/token.js +++ b/test/lib/commands/token.js @@ -110,7 +110,7 @@ t.test('token list parseable output', async t => { ]) }) -t.test('token revoke', async t => { +t.test('token revoke single', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { config: { ...auth }, }) @@ -121,13 +121,13 @@ t.test('token revoke', async t => { }) registry.getTokens(tokens) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[0].key}`).reply(200) + registry.revokeToken(tokens[0].key) await npm.exec('token', ['rm', tokens[0].key.slice(0, 8)]) t.equal(joinedOutput(), 'Removed 1 token') }) -t.test('token revoke multiple tokens', async t => { +t.test('token revoke multiple', async t => { const { npm, joinedOutput } = await loadMockNpm(t, { config: { ...auth }, }) @@ -138,8 +138,8 @@ t.test('token revoke multiple tokens', async t => { }) registry.getTokens(tokens) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[0].key}`).reply(200) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[1].key}`).reply(200) + registry.revokeToken(tokens[0].key) + registry.revokeToken(tokens[1].key) await npm.exec('token', ['rm', tokens[0].key.slice(0, 8), tokens[1].key.slice(0, 8)]) t.equal(joinedOutput(), 'Removed 2 tokens') @@ -159,7 +159,7 @@ t.test('token revoke json output', async t => { }) registry.getTokens(tokens) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[0].key}`).reply(200) + registry.revokeToken(tokens[0].key) await npm.exec('token', ['rm', tokens[0].key.slice(0, 8)]) const parsed = JSON.parse(joinedOutput()) @@ -180,7 +180,7 @@ t.test('token revoke parseable output', async t => { }) registry.getTokens(tokens) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[0].key}`).reply(200) + registry.revokeToken(tokens[0].key) await npm.exec('token', ['rm', tokens[0].key.slice(0, 8)]) t.equal(joinedOutput(), tokens[0].key, 'logs the token as a string') }) @@ -195,7 +195,7 @@ t.test('token revoke by token', async t => { authorization: authToken, }) registry.getTokens(tokens) - registry.nock.delete(`/-/npm/v1/tokens/token/${tokens[0].token}`).reply(200) + registry.revokeToken(tokens[0].token) await npm.exec('token', ['rm', tokens[0].token]) t.equal(joinedOutput(), 'Removed 1 token') }) @@ -240,75 +240,48 @@ t.test('token revoke unknown token', async t => { ) }) -t.test('token create', async t => { - const cidr = ['10.0.0.0/8', '192.168.1.0/24'] +t.test('token create defaults', async t => { const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, - cidr, name: 'test-token', password: 'test-password', }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - authorization: authToken, - }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'test-token' && - body.password === 'test-password' && - body.cidr_whitelist.length === 2 && - body.token_description === undefined - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - cidr_whitelist: cidr, - created: new Date().toISOString(), - }) - await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - 'with IP whitelist: 10.0.0.0/8,192.168.1.0/24', - ]) -}) -t.test('token create read only', async t => { - const { npm, outputs } = await loadMockNpm(t, { - config: { - ...auth, - name: 'readonly-token', - password: 'test-password', - }, - }) const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'readonly-token' && - body.password === 'test-password' && - body.token_description === undefined - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-only', - created: new Date().toISOString(), + + registry.createToken({ + name: 'test-token', + password: 'test-password', + }, { + access: 'publish', }) + await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created read only token n3wt0k3n', - ]) + t.match(outputs, ['Created publish token n3wt0k3n']) }) -t.test('token create with expiry', async t => { - const expires = 30 +t.test('token create extra token attributes', async t => { const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, - name: 'expiry-token', + 'bypass-2fa': true, + cidr: ['10.0.0.0/8', '192.168.1.0/24'], + expires: 1000, + name: 'extras-token', + orgs: ['@npmcli'], + 'orgs-permission': 'read-write', + packages: ['@npmcli/test-package'], + 'packages-and-scopes-permission': 'read-only', + 'packages-all': true, password: 'test-password', - expires, + scopes: ['@npmcli'], + 'token-description': 'test token', }, }) const registry = new MockRegistry({ @@ -316,278 +289,159 @@ t.test('token create with expiry', async t => { registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'expiry-token' && - body.password === 'test-password' && - body.expires === 30 - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-only', - created: new Date().toISOString(), - expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + + const expires = new Date() + registry.createToken({ + bypass_2fa: true, + cidr_whitelist: ['10.0.0.0/8', '192.168.1.0/24'], + description: 'test token', + expires: 1000, + name: 'extras-token', + orgs_permission: 'read-write', + orgs: ['@npmcli'], + packages_all: true, + packages_and_scopes_permission: 'read-only', + packages: ['@npmcli/test-package'], + password: 'test-password', + scopes: ['@npmcli'], + }, { + cidr_whitelist: ['10.0.0.0/8', '192.168.1.0/24'], + expires, }) + await npm.exec('token', ['create']) t.match(outputs, [ 'Created read only token n3wt0k3n', + 'with IP whitelist: 10.0.0.0/8,192.168.1.0/24', + `expires: ${expires.toISOString()}`, ]) - t.match(outputs.join('\n'), /expires:/) }) -t.test('token create with description', async t => { +t.test('token create access.read-only', async t => { const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, - name: 'description-token', + name: 'test-token', password: 'test-password', - 'token-description': 'My custom token for CI/CD', }, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - authorization: authToken, - }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'description-token' && - body.password === 'test-password' && - body.token_description === 'My custom token for CI/CD' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), - }) - await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) -}) -t.test('token create with packages', async t => { - const packages = ['@scope/pkg1', 'pkg2'] - const { npm, outputs } = await loadMockNpm(t, { - config: { - ...auth, - name: 'packages-token', - password: 'test-password', - packages, - 'packages-and-scopes-permission': 'read-write', - }, - }) const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'packages-token' && - body.password === 'test-password' && - body.packages.length === 2 && - body.packages[0] === '@scope/pkg1' && - body.packages[1] === 'pkg2' && - body.packages_and_scopes_permission === 'read-write' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), - }) - await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) -}) -t.test('token create with packages-all', async t => { - const { npm, outputs } = await loadMockNpm(t, { - config: { - ...auth, - name: 'all-packages-token', - password: 'test-password', - 'packages-all': true, - 'packages-and-scopes-permission': 'read-write', - }, - }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - authorization: authToken, - }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'all-packages-token' && - body.password === 'test-password' && - body.packages_all === true && - body.packages_and_scopes_permission === 'read-write' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), + registry.createToken({ + name: 'test-token', + password: 'test-password', + }, { + access: 'read-only', }) - await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) -}) -t.test('token create with scopes', async t => { - const scopes = ['@scope1', '@scope2'] - const { npm, outputs } = await loadMockNpm(t, { - config: { - ...auth, - name: 'scopes-token', - password: 'test-password', - scopes, - 'packages-and-scopes-permission': 'read-write', - }, - }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - authorization: authToken, - }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'scopes-token' && - body.password === 'test-password' && - body.scopes.length === 2 && - body.scopes[0] === '@scope1' && - body.scopes[1] === '@scope2' && - body.packages_and_scopes_permission === 'read-write' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), - }) await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) + t.match(outputs, ['Created read only token n3wt0k3n']) }) -t.test('token create with orgs', async t => { - const orgs = ['org1', 'org2'] +t.test('token create readonly', async t => { const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, - name: 'orgs-token', + name: 'test-token', password: 'test-password', - orgs, - 'orgs-permission': 'read-write', }, + }, { + readonly: true, }) - const registry = new MockRegistry({ - tap: t, - registry: npm.config.get('registry'), - authorization: authToken, - }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'orgs-token' && - body.password === 'test-password' && - body.orgs.length === 2 && - body.orgs[0] === 'org1' && - body.orgs[1] === 'org2' && - body.orgs_permission === 'read-write' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), - }) - await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) -}) -t.test('token create with bypass-2fa', async t => { - const { npm, outputs } = await loadMockNpm(t, { - config: { - ...auth, - name: 'bypass2fa-token', - password: 'test-password', - 'bypass-2fa': true, - }, - }) const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'bypass2fa-token' && - body.password === 'test-password' && - body.bypass_2fa === true - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - created: new Date().toISOString(), + + registry.createToken({ + name: 'test-token', + password: 'test-password', + }, { + access: 'read-only', }) + await npm.exec('token', ['create']) - t.match(outputs, [ - 'Created publish token n3wt0k3n', - ]) + t.match(outputs, ['Created read only token n3wt0k3n']) }) t.test('token create json output', async t => { - const cidr = ['10.0.0.0/8', '192.168.1.0/24'] const { npm, joinedOutput } = await loadMockNpm(t, { config: { ...auth, json: true, - cidr, - name: 'json-token', + name: 'test-token', password: 'test-password', }, }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'json-token' && - body.password === 'test-password' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - cidr_whitelist: cidr, - created: new Date().toISOString(), + + const created = new Date() + registry.createToken({ + name: 'test-token', + password: 'test-password', + }, { + created, + other: 'attr', }) + await npm.exec('token', ['create']) - const parsed = JSON.parse(joinedOutput()) - t.match( - parsed, - { token: 'n3wt0k3n', access: 'read-write', cidr_whitelist: cidr } - ) - t.ok(parsed.created, 'also returns created') + t.match(JSON.parse(joinedOutput()), { + access: 'read-only', + created: created.toISOString(), + id: '0xdeadbeef', + name: 'test-token', + other: 'attr', + password: 'test-password', + token: 'n3wt0k3n', + }) }) t.test('token create parseable output', async t => { - const cidr = ['10.0.0.0/8', '192.168.1.0/24'] const { npm, outputs } = await loadMockNpm(t, { config: { ...auth, parseable: true, - cidr, - name: 'parseable-token', + name: 'test-token', password: 'test-password', }, }) + const registry = new MockRegistry({ tap: t, registry: npm.config.get('registry'), authorization: authToken, }) - registry.nock.post('/-/npm/v1/tokens', body => { - return body.name === 'parseable-token' && - body.password === 'test-password' - }).reply(201, { - token: 'n3wt0k3n', - access: 'read-write', - cidr_whitelist: cidr, - created: new Date().toISOString(), + + const created = new Date() + registry.createToken({ + name: 'test-token', + password: 'test-password', + }, { + access: 'publish', + created, }) + await npm.exec('token', ['create']) - // In parseable mode, all fields are output as key\tvalue pairs - t.match(outputs.join('\n'), /token\tn3wt0k3n/) - t.match(outputs.join('\n'), /created\t/) - t.match(outputs.join('\n'), /cidr_whitelist\t10.0.0.0\/8,192.168.1.0\/24/) + t.match(outputs, [ + 'id\t0xdeadbeef', + 'token\tn3wt0k3n', + `created\t${created.toISOString()}`, + 'access\tpublish', + 'name\ttest-token', + 'password\ttest-password', + ]) }) t.test('token create ipv6 cidr', async t => { From 04f89bdf7fd01e74c9cbf7f4f8dd493bb7697098 Mon Sep 17 00:00:00 2001 From: Gar Date: Tue, 18 Nov 2025 10:02:43 -0800 Subject: [PATCH 3/3] fixup: test snapshots --- .../test/type-description.js.test.cjs | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs index 270916887c650..7325654569b3d 100644 --- a/workspaces/config/tap-snapshots/test/type-description.js.test.cjs +++ b/workspaces/config/tap-snapshots/test/type-description.js.test.cjs @@ -55,6 +55,9 @@ Object { "boolean value (true or false)", Function String(), ], + "bypass-2fa": Array [ + "boolean value (true or false)", + ], "ca": Array [ null, Function String(), @@ -147,6 +150,10 @@ Object { null, "boolean value (true or false)", ], + "expires": Array [ + null, + "numeric value", + ], "fetch-retries": Array [ "numeric value", ], @@ -328,6 +335,10 @@ Object { "message": Array [ Function String(), ], + "name": Array [ + null, + Function String(), + ], "node-gyp": Array [ "valid filesystem path", ], @@ -360,6 +371,17 @@ Object { null, "boolean value (true or false)", ], + "orgs": Array [ + null, + Function String(), + Function Array(), + ], + "orgs-permission": Array [ + null, + "read-only", + "read-write", + "no-access", + ], "os": Array [ null, Function String(), @@ -381,9 +403,27 @@ Object { "package-lock-only": Array [ "boolean value (true or false)", ], + "packages": Array [ + null, + Function String(), + Function Array(), + ], + "packages-all": Array [ + "boolean value (true or false)", + ], + "packages-and-scopes-permission": Array [ + null, + "read-only", + "read-write", + "no-access", + ], "parseable": Array [ "boolean value (true or false)", ], + "password": Array [ + null, + Function String(), + ], "prefer-dedupe": Array [ "boolean value (true or false)", ], @@ -468,6 +508,11 @@ Object { "scope": Array [ Function String(), ], + "scopes": Array [ + null, + Function String(), + Function Array(), + ], "script-shell": Array [ null, Function String(), @@ -511,6 +556,10 @@ Object { "timing": Array [ "boolean value (true or false)", ], + "token-description": Array [ + null, + Function String(), + ], "umask": Array [ "octal number in range 0o000..0o777 (0..511)", ],