diff --git a/.gitignore b/.gitignore index 1342f40..a2cfcd6 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ dist .eslintcache # Project-specific -.npm-pkg-settings.session.txt +.npm-pkg-settings.cookies.json # Agent skills from npm packages (managed by skills-npm) **/skills/npm-* diff --git a/README.md b/README.md index 8caa63e..4447c2a 100644 --- a/README.md +++ b/README.md @@ -15,20 +15,12 @@ This package automates all of it — trusted publishers, publishing access, and ## Prerequisites -1. **`curl-impersonate`** — Bypasses Cloudflare bot protection on npmjs.com. Install the [`curl-impersonate`](https://github.com/lexiforest/curl-impersonate) binary: +**TOTP secret** — The base32 secret key used to generate your npm 2FA codes. If you already know your secret, you can use it directly. Otherwise, this package includes a helper command to extract it from a Google Authenticator QR code screenshot or migration URL: - ```sh - # macOS (Apple Silicon) - curl -L https://github.com/lexiforest/curl-impersonate/releases/latest/download/curl-impersonate-v1.5.1.arm64-macos.tar.gz | tar xz - cp curl-impersonate curl_chrome145 /usr/local/bin/ - ``` - -2. **TOTP secret** — The base32 secret key used to generate your npm 2FA codes. If you already know your secret, you can use it directly. Otherwise, this package includes a helper command to extract it from a Google Authenticator QR code screenshot or migration URL: - - ```sh - npx npm-pkg-settings decode-secret ./qr-code.png - npx npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' - ``` +```sh +npx npm-pkg-settings decode-secret ./qr-code.png +npx npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' +``` ## Install @@ -153,7 +145,7 @@ const npm = createClient({ await npm.login() ``` -The session is saved to `.npm-pkg-settings.session.txt`, so subsequent runs reuse it without re-authenticating. +The session is saved to `.npm-pkg-settings.cookies.json`, so subsequent runs reuse it without re-authenticating. Store credentials in a `.env` file: @@ -284,7 +276,7 @@ Returns an `NpmClient`. | `otpSecret` | `string` | TOTP secret (base32) for 2FA | | `username` | `string?` | npm username (required for `login()`) | | `password` | `string?` | npm password (required for `login()`) | -| `sessionFile` | `string?` | Session file path (default: `.npm-pkg-settings.session.txt`) | +| `sessionFile` | `string?` | Session file path (default: `.npm-pkg-settings.cookies.json`) | ### `NpmClient` diff --git a/package.json b/package.json index 1431825..a4fe342 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,14 @@ "dependencies": { "ansis": "^4.2.0", "cleye": "^2.2.1", + "impit": "^0.11.0", "jsqr": "^1.4.0", "linkedom": "^0.18.12", - "nano-spawn": "^2.0.0", "otplib": "^13.3.0", "pngjs": "^7.0.0", - "tasuku": "3.0.0-beta.3" + "tasuku": "3.0.0-beta.3", + "tough-cookie": "^6.0.1", + "tough-cookie-file-store": "^3.3.0" }, "devDependencies": { "@types/node": "^25.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3cb7fc..6c95daa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,15 +14,15 @@ importers: cleye: specifier: ^2.2.1 version: 2.2.1 + impit: + specifier: ^0.11.0 + version: 0.11.0 jsqr: specifier: ^1.4.0 version: 1.4.0 linkedom: specifier: ^0.18.12 version: 0.18.12 - nano-spawn: - specifier: ^2.0.0 - version: 2.0.0 otplib: specifier: ^13.3.0 version: 13.3.0 @@ -32,6 +32,12 @@ importers: tasuku: specifier: 3.0.0-beta.3 version: 3.0.0-beta.3 + tough-cookie: + specifier: ^6.0.1 + version: 6.0.1 + tough-cookie-file-store: + specifier: ^3.3.0 + version: 3.3.0 devDependencies: '@types/node': specifier: ^25.3.0 @@ -1832,6 +1838,58 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + impit-darwin-arm64@0.11.0: + resolution: {integrity: sha512-XZcgJQ49hVGoa+bXmXqkvSucyo99X13zggMjqg5lU1SYChpgtsmDG2OyhmKt+if07Y+HtB6EAlCBLl6HPPlbbQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + impit-darwin-x64@0.11.0: + resolution: {integrity: sha512-lyz/HnElBkr/e13pTrBWDocfjVVR6eKrOZmnVeCEPxYlNuPCOslhHp2p+1BdgIQO/8s2X2MVeS0zVSXq3hNp+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + impit-linux-arm64-gnu@0.11.0: + resolution: {integrity: sha512-xdvbpoPnrAHDtetZjfD6/zqswhg9CRAd7f3YyBFzzNg3aoBRhgMFrdD2BLEWAvaq1PZkj+/emCaPZfJZJfPuJg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + impit-linux-arm64-musl@0.11.0: + resolution: {integrity: sha512-w7isVF4RfVynopjGSP+a3/6KJmL7MzdEw2niIi9YjRnCRDPi4XEmxDm9XScB7vUE8E4s7LT8QKa/SIb3MEpvFQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + impit-linux-x64-gnu@0.11.0: + resolution: {integrity: sha512-Y0NBSiFn79G2CXUx2J+a8W2e53ZS7smohsZX18XY8i9LxLf2FQ6pt7e1eWVDHEdo298r5w4EFPc46Gdg6npw+A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + impit-linux-x64-musl@0.11.0: + resolution: {integrity: sha512-UmahyHiqcNTCYnAgW+SjQwfyqfALYxXeN3Likuf/aS/tzlTP/CPmtNu1O+Vl7G8dZaWRlBNtAwvLeKlkUKBL5A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + impit-win32-arm64-msvc@0.11.0: + resolution: {integrity: sha512-LppsL30N+EgVx1tsBNsYz2vf8aM+RXpin/9Szzs8G82PD5QmOnrhJE6JTpzyfVYebjn/UdJcIqFzfcZRCciLOg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + impit-win32-x64-msvc@0.11.0: + resolution: {integrity: sha512-fXIrgD8EdxDBic90DvcJ350lbeHYTQGybNEQNW7zQrSMEht0A/r7mm9yI1VhhSOiT7glCl96CAvG3mTSGqAipA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + impit@0.11.0: + resolution: {integrity: sha512-968YrfzZN5CCgHs/n/yAbPgetq+bOreQOI9UQXmHK3srRs24g+m9CNGL8tRWUIZCK0tnc+baBJ0nw+8saHz0qw==} + engines: {node: '>= 20'} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -2678,10 +2736,25 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tldts-core@7.0.27: + resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==} + + tldts@7.0.27: + resolution: {integrity: sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie-file-store@3.3.0: + resolution: {integrity: sha512-FbO/cOi/jp4wweo8soVNG/ZjDsgpBZWqaxWwu7gRKvsjg/Qt44kStp87VLfJnin749DlTbZDYvV1wuSr5jly2g==} + engines: {node: '>=16'} + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -4690,6 +4763,41 @@ snapshots: ignore@7.0.5: {} + impit-darwin-arm64@0.11.0: + optional: true + + impit-darwin-x64@0.11.0: + optional: true + + impit-linux-arm64-gnu@0.11.0: + optional: true + + impit-linux-arm64-musl@0.11.0: + optional: true + + impit-linux-x64-gnu@0.11.0: + optional: true + + impit-linux-x64-musl@0.11.0: + optional: true + + impit-win32-arm64-msvc@0.11.0: + optional: true + + impit-win32-x64-msvc@0.11.0: + optional: true + + impit@0.11.0: + optionalDependencies: + impit-darwin-arm64: 0.11.0 + impit-darwin-x64: 0.11.0 + impit-linux-arm64-gnu: 0.11.0 + impit-linux-arm64-musl: 0.11.0 + impit-linux-x64-gnu: 0.11.0 + impit-linux-x64-musl: 0.11.0 + impit-win32-arm64-msvc: 0.11.0 + impit-win32-x64-msvc: 0.11.0 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -5861,10 +5969,24 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@7.0.27: {} + + tldts@7.0.27: + dependencies: + tldts-core: 7.0.27 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + tough-cookie-file-store@3.3.0: + dependencies: + tough-cookie: 6.0.1 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.27 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/skills/npm-pkg-settings/SKILL.md b/skills/npm-pkg-settings/SKILL.md index 83521b8..d0f7e58 100644 --- a/skills/npm-pkg-settings/SKILL.md +++ b/skills/npm-pkg-settings/SKILL.md @@ -5,7 +5,7 @@ description: Manages npm package publish settings — listing packages, viewing/ # npm-pkg-settings -CLI and programmatic API for managing npm package publish settings. Works by automating the npm website via browser-impersonating HTTP requests (`curl_chrome145`), since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. +CLI and programmatic API for managing npm package publish settings. Works by automating the npm website, since npm has no public API for package settings. Automatically handles OTP generation and submission during all operations. ## Setup @@ -33,10 +33,10 @@ npm-pkg-settings decode-secret 'otpauth-migration://offline?data=...' ### Session and gitignore -The CLI persists a browser session to `.npm-pkg-settings.session.txt`. Add to `.gitignore`: +The CLI persists cookies to `.npm-pkg-settings.cookies.json`. Add to `.gitignore`: ``` -.npm-pkg-settings.session.txt +.npm-pkg-settings.cookies.json .env ``` @@ -110,7 +110,7 @@ const client = createClient({ otpSecret: process.env.NPM_OTP_SECRET!, username: process.env.NPM_USERNAME, // optional — enables login() password: process.env.NPM_PASSWORD, // optional — enables login() - sessionFile: '.npm-pkg-settings.session.txt', // optional, this is the default + sessionFile: '.npm-pkg-settings.cookies.json', // optional, this is the default }) ``` diff --git a/src/api/add-maintainer.ts b/src/api/add-maintainer.ts index 384a35c..0ed946f 100644 --- a/src/api/add-maintainer.ts +++ b/src/api/add-maintainer.ts @@ -1,16 +1,16 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const addMaintainer = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, npmUsername: string, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; - const result = await submitWithOtp(context, accessPath, new URLSearchParams({ + const result = await submitWithOtp(client, accessPath, new URLSearchParams({ add: npmUsername, csrftoken: settings.csrfToken, })); diff --git a/src/api/get-package-access.ts b/src/api/get-package-access.ts index b9ba740..4e1e055 100644 --- a/src/api/get-package-access.ts +++ b/src/api/get-package-access.ts @@ -1,21 +1,21 @@ -import type { NpmContext, PackageSettings } from '../types.ts'; +import type { NpmInternalClient, PackageSettings } from '../types.ts'; import { parsePackageAccess } from '../parsers/package-access.ts'; import { authenticatedGet } from '../utils/authenticated-get.ts'; export const getPackageAccess = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, ): Promise => { - const { csrfToken: _, ...settings } = await getAccessPageWithCsrf(context, packageName); + const { csrfToken: _, ...settings } = await getAccessPageWithCsrf(client, packageName); return settings; }; // Internal: returns csrfToken too (needed by mutation functions) export const getAccessPageWithCsrf = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, ) => { - const response = await authenticatedGet(context, `package/${packageName}/access`); + const response = await authenticatedGet(client, `package/${packageName}/access`); if (response.status !== 200) { throw new Error(`Failed to fetch access page for ${packageName} (status ${response.status})`); } diff --git a/src/api/get-username.ts b/src/api/get-username.ts index 9772c7b..a347798 100644 --- a/src/api/get-username.ts +++ b/src/api/get-username.ts @@ -1,12 +1,11 @@ -import type { NpmContext } from '../types.ts'; -import { npmFetch } from '../utils/npm-fetch.ts'; +import type { NpmInternalClient } from '../types.ts'; -export const getUsername = async (context: NpmContext): Promise => { - if (context.cachedUsername) { - return context.cachedUsername; +export const getUsername = async (client: NpmInternalClient): Promise => { + if (client.cachedUsername) { + return client.cachedUsername; } - const response = await npmFetch(context, '', { + const response = await client.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { @@ -19,6 +18,6 @@ export const getUsername = async (context: NpmContext): Promise => { throw new Error('Not logged in — no user found in session'); } - context.cachedUsername = username; + client.cachedUsername = username; return username; }; diff --git a/src/api/link-trusted-publisher.ts b/src/api/link-trusted-publisher.ts index 0cf1866..1c735f9 100644 --- a/src/api/link-trusted-publisher.ts +++ b/src/api/link-trusted-publisher.ts @@ -1,13 +1,13 @@ -import type { NpmContext, TrustedPublisher } from '../types.ts'; +import type { NpmInternalClient, TrustedPublisher } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const linkTrustedPublisher = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, publisher: TrustedPublisher, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; const body = publisher.type === 'github' @@ -32,7 +32,7 @@ export const linkTrustedPublisher = async ( csrftoken: settings.csrfToken, }); - const result = await submitWithOtp(context, accessPath, body); + const result = await submitWithOtp(client, accessPath, body); if (result.status < 300 || result.status >= 400) { throw new Error(`Failed to link trusted publisher for ${packageName} (status ${result.status})`); diff --git a/src/api/list-packages.ts b/src/api/list-packages.ts index 705cad5..fa5183f 100644 --- a/src/api/list-packages.ts +++ b/src/api/list-packages.ts @@ -1,17 +1,15 @@ -import type { NpmContext, PackageListItem } from '../types.ts'; -import { npmFetch } from '../utils/npm-fetch.ts'; +import type { NpmInternalClient, PackageListItem } from '../types.ts'; import { getUsername } from './get-username.ts'; -export const listPackages = async (context: NpmContext): Promise => { - const username = await getUsername(context); +export const listPackages = async (client: NpmInternalClient): Promise => { + const username = await getUsername(client); const perPage = 100; const allPackages: PackageListItem[] = []; let page = 0; let total = Infinity; while (allPackages.length < total) { - const response = await npmFetch( - context, + const response = await client.fetch( `settings/${username}/packages?page=${page}&perPage=${perPage}`, { headers: { 'x-spiferack': '1' } }, ); diff --git a/src/api/set-publishing-access.ts b/src/api/set-publishing-access.ts index 153a2c8..29385ff 100644 --- a/src/api/set-publishing-access.ts +++ b/src/api/set-publishing-access.ts @@ -1,16 +1,16 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { submitWithOtp } from '../utils/submit-with-otp.ts'; import { getAccessPageWithCsrf } from './get-package-access.ts'; export const setPublishingAccess = async ( - context: NpmContext, + client: NpmInternalClient, packageName: string, access: string, ) => { - const settings = await getAccessPageWithCsrf(context, packageName); + const settings = await getAccessPageWithCsrf(client, packageName); const accessPath = `package/${packageName}/access`; - const result = await submitWithOtp(context, accessPath, new URLSearchParams({ + const result = await submitWithOtp(client, accessPath, new URLSearchParams({ publishingAccess: access, csrftoken: settings.csrfToken, })); diff --git a/src/context/login.ts b/src/context/login.ts index 752b192..36e0b9b 100644 --- a/src/context/login.ts +++ b/src/context/login.ts @@ -1,11 +1,10 @@ import { parseLoginPage } from '../parsers/login.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { generateOtp } from '../utils/generate-otp.ts'; -import { npmFetch } from '../utils/npm-fetch.ts'; -const isLoggedIn = async (context: NpmContext): Promise => { - const response = await npmFetch(context, '', { +const isLoggedIn = async (client: NpmInternalClient): Promise => { + const response = await client.fetch('', { headers: { 'x-spiferack': '1' }, }); if (response.status !== 200) { @@ -13,23 +12,23 @@ const isLoggedIn = async (context: NpmContext): Promise => { } const data = await response.json() as { user?: { name?: string } }; if (data.user?.name) { - context.cachedUsername = data.user.name; + client.cachedUsername = data.user.name; return true; } return false; }; export const performLogin = async ( - context: NpmContext, + client: NpmInternalClient, credentials: { username: string; password: string; }, ): Promise<{ skipped: boolean }> => { - if (await isLoggedIn(context)) { + if (await isLoggedIn(client)) { return { skipped: true }; } // GET /login to get CSRF token - const loginPage = await npmFetch(context, 'login'); + const loginPage = await client.fetch('login'); if (loginPage.status !== 200) { throw new Error(`Failed to fetch login page (status ${loginPage.status})`); } @@ -37,7 +36,7 @@ export const performLogin = async ( const { csrfToken } = parseLoginPage(await loginPage.text()); // POST /login with credentials - const loginResponse = await npmFetch(context, 'login', { + const loginResponse = await client.fetch('login', { method: 'POST', body: new URLSearchParams({ username: credentials.username, @@ -48,15 +47,15 @@ export const performLogin = async ( // Follow redirect to OTP page if (loginResponse.status >= 300 && loginResponse.status < 400) { - const { location } = loginResponse.headers; + const location = loginResponse.headers.get('location'); if (location) { - const otpPage = await npmFetch(context, location); + const otpPage = await client.fetch(location); const otpPageBody = await otpPage.text(); if (otpPage.status === 200 && otpPageBody.includes('One-time Password')) { const { action, csrfToken: otpCsrf, formName } = parseOtpPage(otpPageBody); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/src/create-client.ts b/src/create-client.ts index 1a91cf8..21cc29b 100644 --- a/src/create-client.ts +++ b/src/create-client.ts @@ -1,4 +1,6 @@ -import { createFetch } from './curl-fetch.ts'; +import { Impit } from 'impit'; +import { CookieJar } from 'tough-cookie'; +import FileCookieStore from 'tough-cookie-file-store'; import { performLogin } from './context/login.ts'; import { getUsername } from './api/get-username.ts'; import { listPackages } from './api/list-packages.ts'; @@ -9,11 +11,22 @@ import { addMaintainer } from './api/add-maintainer.ts'; import type { CreateClientOptions, NpmClient } from './types.ts'; import { defaultOtpGenerator } from './utils/default-otp-generator.ts'; -export const defaultSessionFile = '.npm-pkg-settings.session.txt'; +export const defaultSessionFile = '.npm-pkg-settings.cookies.json'; + +const npmBaseUrl = 'https://www.npmjs.com/'; export const createClient = (options: CreateClientOptions): NpmClient => { - const context = { - fetch: createFetch({ sessionFile: options.sessionFile ?? defaultSessionFile }), + const impit = new Impit({ + browser: 'chrome142', + cookieJar: new CookieJar(new FileCookieStore(options.sessionFile ?? defaultSessionFile)), + followRedirects: false, + }); + + const client = { + fetch: (path: string, init?: Record) => impit.fetch( + new URL(path, npmBaseUrl).href, + init, + ), otpSecret: options.otpSecret, otpGenerator: defaultOtpGenerator, cachedUsername: options.username, @@ -24,18 +37,18 @@ export const createClient = (options: CreateClientOptions): NpmClient => { if (!options.username || !options.password) { throw new Error('login() requires username and password in createClient options'); } - return performLogin(context, { + return performLogin(client, { username: options.username, password: options.password, }); }, - listPackages: () => listPackages(context), - getPackageAccess: packageName => getPackageAccess(context, packageName), - setPublishingAccess: (packageName, access) => setPublishingAccess(context, packageName, access), + listPackages: () => listPackages(client), + getPackageAccess: packageName => getPackageAccess(client, packageName), + setPublishingAccess: (packageName, access) => setPublishingAccess(client, packageName, access), linkTrustedPublisher: (packageName, publisher) => ( - linkTrustedPublisher(context, packageName, publisher) + linkTrustedPublisher(client, packageName, publisher) ), - addMaintainer: (packageName, npmUsername) => addMaintainer(context, packageName, npmUsername), - getUsername: () => getUsername(context), + addMaintainer: (packageName, npmUsername) => addMaintainer(client, packageName, npmUsername), + getUsername: () => getUsername(client), }; }; diff --git a/src/curl-fetch.ts b/src/curl-fetch.ts deleted file mode 100644 index d431b04..0000000 --- a/src/curl-fetch.ts +++ /dev/null @@ -1,115 +0,0 @@ -import defaultSpawn from 'nano-spawn'; - -export type SpawnFunction = ( - command: string, - arguments_: string[], -) => Promise<{ stdout: string }>; - -type CurlFetchOptions = { - sessionFile?: string; - spawn?: SpawnFunction; -}; - -export type CurlRequestInit = { - method?: string; - headers?: Record; - body?: URLSearchParams | string; - redirect?: 'follow' | 'manual'; -}; - -export type CurlResponseLike = { - status: number; - statusText: string; - headers: Record; - ok: boolean; - text(): Promise; - json(): Promise; -}; - -const createResponse = ( - status: number, - statusText: string, - headers: Record, - body: string, -): CurlResponseLike => ({ - status, - statusText, - headers, - ok: status >= 200 && status < 300, - text: async () => body, - json: async () => JSON.parse(body), -}); - -const parseResponseHeaders = (raw: string): Record => { - const headers: Record = {}; - for (const line of raw.split('\r\n')) { - const index = line.indexOf(': '); - if (index === -1) { - continue; - } - headers[line.slice(0, index).toLowerCase()] = line.slice(index + 2); - } - return headers; -}; - -export const createFetch = (options: CurlFetchOptions = {}) => { - const { sessionFile, spawn = defaultSpawn } = options; - - return async (url: string, init?: CurlRequestInit): Promise => { - const method = init?.method ?? 'GET'; - - const arguments_: string[] = [ - '-s', - '-D', - '-', - '-o', - '-', - '-X', - method, - ]; - - if (sessionFile) { - arguments_.push('-b', sessionFile, '-c', sessionFile); - } - - if (init?.redirect === 'follow') { - arguments_.push('-L'); - } else { - arguments_.push('--max-redirs', '0'); - } - - if (init?.headers) { - for (const [key, value] of Object.entries(init.headers)) { - arguments_.push('-H', `${key}: ${value}`); - } - } - - if (init?.body) { - const bodyString = init.body instanceof URLSearchParams - ? init.body.toString() - : init.body; - arguments_.push( - '-H', 'content-type: application/x-www-form-urlencoded', '-d', bodyString, - ); - } - - arguments_.push(url); - - const result = await spawn('curl_chrome145', arguments_); - - const separator = result.stdout.indexOf('\r\n\r\n'); - if (separator === -1) { - return createResponse(0, '', {}, result.stdout); - } - const headerSection = result.stdout.slice(0, separator); - const body = result.stdout.slice(separator + 4); - - const statusMatch = headerSection.match(/^HTTP\/\S+ (\d+)\s*(.*)/); - const status = statusMatch ? Number(statusMatch[1]) : 0; - const statusText = statusMatch?.[2]?.trim() ?? ''; - - const headers = parseResponseHeaders(headerSection); - - return createResponse(status, statusText, headers, body); - }; -}; diff --git a/src/index.ts b/src/index.ts index bab22af..8eae85b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ export { addMaintainer } from './api/add-maintainer.ts'; export { validateTrustedPublisher } from './api/validate-trusted-publisher.ts'; export type { NpmClient, - NpmContext, + NpmInternalClient, CreateClientOptions, PackageListItem, PackageSettings, diff --git a/src/types.ts b/src/types.ts index de23ffd..8ff973c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,16 @@ -import type { CurlRequestInit, CurlResponseLike } from './curl-fetch.ts'; - -export type CurlFetch = (url: string, init?: CurlRequestInit) => Promise; - export type OtpGenerator = (secret: string) => Promise; // Internal session state -export type NpmContext = { - fetch: CurlFetch; +export type NpmInternalClient = { + fetch: ( + path: string, + init?: Record, + ) => Promise<{ + status: number; + headers: { get(name: string): string | null }; + text(): Promise; + json(): Promise; + }>; otpSecret: string; otpGenerator: OtpGenerator; cachedUsername?: string; diff --git a/src/utils/authenticated-get.ts b/src/utils/authenticated-get.ts index 126a8fa..543cfeb 100644 --- a/src/utils/authenticated-get.ts +++ b/src/utils/authenticated-get.ts @@ -1,10 +1,10 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import { npmFetch } from './npm-fetch.ts'; + import { generateOtp } from './generate-otp.ts'; const handleOtpEscalation = async ( - context: NpmContext, + client: NpmInternalClient, body: string, ): Promise<{ status: number; body: string; } | undefined> => { @@ -13,9 +13,9 @@ const handleOtpEscalation = async ( } const { action, csrfToken, formName } = parseOtpPage(body); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, @@ -25,9 +25,9 @@ const handleOtpEscalation = async ( }); if (otpResponse.status >= 300 && otpResponse.status < 400) { - const { location } = otpResponse.headers; + const location = otpResponse.headers.get('location'); if (location) { - const finalResponse = await npmFetch(context, location); + const finalResponse = await client.fetch(location); return { status: finalResponse.status, body: await finalResponse.text(), @@ -42,17 +42,17 @@ const handleOtpEscalation = async ( }; export const authenticatedGet = async ( - context: NpmContext, + client: NpmInternalClient, path: string, ): Promise<{ status: number; body: string; }> => { - let response = await npmFetch(context, path); + let response = await client.fetch(path); // Follow redirect (escalation or login) if (response.status >= 300 && response.status < 400) { - const { location } = response.headers; + const location = response.headers.get('location'); if (location) { - response = await npmFetch(context, location); + response = await client.fetch(location); } } @@ -66,7 +66,7 @@ export const authenticatedGet = async ( } // Handle OTP escalation page - const otpResult = await handleOtpEscalation(context, body); + const otpResult = await handleOtpEscalation(client, body); if (otpResult) { return otpResult; } diff --git a/src/utils/generate-otp.ts b/src/utils/generate-otp.ts index 96fddcd..f787aa3 100644 --- a/src/utils/generate-otp.ts +++ b/src/utils/generate-otp.ts @@ -1,3 +1,3 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; -export const generateOtp = (context: NpmContext) => context.otpGenerator(context.otpSecret); +export const generateOtp = (client: NpmInternalClient) => client.otpGenerator(client.otpSecret); diff --git a/src/utils/npm-fetch.ts b/src/utils/npm-fetch.ts deleted file mode 100644 index eb205de..0000000 --- a/src/utils/npm-fetch.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { NpmContext } from '../types.ts'; - -const baseUrl = 'https://www.npmjs.com'; - -export const npmFetch = ( - context: NpmContext, - path: string, - init?: Parameters[1], -) => context.fetch( - path.startsWith('http') ? path : `${baseUrl}/${path}`, - init, -); diff --git a/src/utils/submit-with-otp.ts b/src/utils/submit-with-otp.ts index 4da1b12..de92809 100644 --- a/src/utils/submit-with-otp.ts +++ b/src/utils/submit-with-otp.ts @@ -1,14 +1,14 @@ -import type { NpmContext } from '../types.ts'; +import type { NpmInternalClient } from '../types.ts'; import { parseOtpPage } from '../parsers/otp.ts'; -import { npmFetch } from './npm-fetch.ts'; + import { generateOtp } from './generate-otp.ts'; export const submitWithOtp = async ( - context: NpmContext, + client: NpmInternalClient, path: string, body: URLSearchParams, ) => { - const response = await npmFetch(context, path, { + const response = await client.fetch(path, { method: 'POST', body, }); @@ -16,9 +16,9 @@ export const submitWithOtp = async ( if (response.status === 200 && responseBody.includes('One-time Password')) { const { action, csrfToken, formName } = parseOtpPage(responseBody); - const otp = await generateOtp(context); + const otp = await generateOtp(client); - const otpResponse = await npmFetch(context, action, { + const otpResponse = await client.fetch(action, { method: 'POST', body: new URLSearchParams({ otp, diff --git a/tests/index.ts b/tests/index.ts index 90376c2..979afe7 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -7,14 +7,7 @@ describe('npm-pkg-settings', () => { import('./specs/parsers/package-access.ts'); }); - describe('curl-fetch', () => { - import('./specs/curl-fetch/curl-fetch.ts'); - import('./specs/curl-fetch/parse-headers.ts'); - import('./specs/curl-fetch/parse-raw-response.ts'); - }); - describe('utils', () => { - import('./specs/utils/npm-fetch.ts'); import('./specs/utils/generate-otp.ts'); import('./specs/utils/authenticated-get.ts'); import('./specs/utils/submit-with-otp.ts'); @@ -36,6 +29,11 @@ describe('npm-pkg-settings', () => { describe('create-client', () => { import('./specs/create-client.ts'); + import('./specs/create-client-fetch.ts'); + }); + + describe('smoke', () => { + import('./specs/smoke/cloudflare-bypass.ts'); }); describe('cli', () => { diff --git a/tests/specs/api/add-maintainer.ts b/tests/specs/api/add-maintainer.ts index 8dd350e..38b79ad 100644 --- a/tests/specs/api/add-maintainer.ts +++ b/tests/specs/api/add-maintainer.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { addMaintainer } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = () => { - const context = { + const client = { csrftoken: 'csrf-token', package: 'my-pkg', formData: { @@ -32,13 +30,13 @@ const makeAccessHtml = () => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('addMaintainer', () => { test('submits add field with username', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -46,20 +44,20 @@ describe('addMaintainer', () => { return mockResponse(200, makeAccessHtml()); }); - await addMaintainer(context, 'my-pkg', 'newuser'); + await addMaintainer(client, 'my-pkg', 'newuser'); expect(postBody).toContain('add=newuser'); expect(postBody).toContain('csrftoken=csrf-token'); }); test('throws on failure', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(addMaintainer(context, 'my-pkg', 'newuser')) + await expect(addMaintainer(client, 'my-pkg', 'newuser')) .rejects.toThrow('Failed to add maintainer'); }); }); diff --git a/tests/specs/api/get-package-access.ts b/tests/specs/api/get-package-access.ts index 49f9340..8574764 100644 --- a/tests/specs/api/get-package-access.ts +++ b/tests/specs/api/get-package-access.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { getPackageAccess } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = (overrides: Record = {}) => { - const context = { + const client = { csrftoken: 'csrf-123', package: 'my-pkg', packageVersion: { repository: 'https://github.com/user/repo' }, @@ -37,7 +35,7 @@ const makeAccessHtml = (overrides: Record = {}) => { }], ...overrides, }; - return ``; + return ``; }; const otpPageHtml = ` @@ -50,9 +48,9 @@ const otpPageHtml = ` describe('getPackageAccess', () => { test('returns settings for direct 200 response', async () => { - const context = mockContext(async () => mockResponse(200, makeAccessHtml())); + const client = mockClient(async () => mockResponse(200, makeAccessHtml())); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect(settings.packageName).toBe('my-pkg'); expect(settings.repository).toBe('https://github.com/user/repo'); expect(settings.publishingAccess).toBe('tfa-required-unless-automation'); @@ -63,15 +61,15 @@ describe('getPackageAccess', () => { }); test('strips csrfToken from public result', async () => { - const context = mockContext(async () => mockResponse(200, makeAccessHtml())); + const client = mockClient(async () => mockResponse(200, makeAccessHtml())); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect('csrfToken' in settings).toBe(false); }); test('follows redirect and handles OTP escalation', async () => { let callIndex = 0; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET access page → redirect to escalation if (callIndex === 1) { @@ -89,14 +87,14 @@ describe('getPackageAccess', () => { return mockResponse(200, makeAccessHtml()); }); - const settings = await getPackageAccess(context, 'my-pkg'); + const settings = await getPackageAccess(client, 'my-pkg'); expect(settings.packageName).toBe('my-pkg'); expect(callIndex).toBe(4); }); test('throws on non-200 after all retries', async () => { - const context = mockContext(async () => mockResponse(429, 'rate limited')); + const client = mockClient(async () => mockResponse(429, 'rate limited')); - await expect(getPackageAccess(context, 'my-pkg')).rejects.toThrow('Failed to fetch access page'); + await expect(getPackageAccess(client, 'my-pkg')).rejects.toThrow('Failed to fetch access page'); }); }); diff --git a/tests/specs/api/get-username.ts b/tests/specs/api/get-username.ts index b60909c..13e74eb 100644 --- a/tests/specs/api/get-username.ts +++ b/tests/specs/api/get-username.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { getUsername } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,40 +17,40 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('getUsername', () => { test('fetches from session when not cached', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'sessionuser' } }), )); - const username = await getUsername(context); + const username = await getUsername(client); expect(username).toBe('sessionuser'); }); test('caches after first fetch', async () => { let callCount = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callCount += 1; return mockResponse(200, JSON.stringify({ user: { name: 'cached' } })); }); - await getUsername(context); - await getUsername(context); + await getUsername(client); + await getUsername(client); expect(callCount).toBe(1); - expect(context.cachedUsername).toBe('cached'); + expect(client.cachedUsername).toBe('cached'); }); test('throws when not logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: null }), )); - await expect(getUsername(context)).rejects.toThrow('Not logged in'); + await expect(getUsername(client)).rejects.toThrow('Not logged in'); }); test('throws on non-200 response', async () => { - const context = mockContext(async () => mockResponse(500, '')); + const client = mockClient(async () => mockResponse(500, '')); - await expect(getUsername(context)).rejects.toThrow('Failed to fetch homepage'); + await expect(getUsername(client)).rejects.toThrow('Failed to fetch homepage'); }); }); diff --git a/tests/specs/api/link-trusted-publisher.ts b/tests/specs/api/link-trusted-publisher.ts index 7f37520..4fdc59e 100644 --- a/tests/specs/api/link-trusted-publisher.ts +++ b/tests/specs/api/link-trusted-publisher.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { linkTrustedPublisher } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = () => { - const context = { + const client = { csrftoken: 'csrf-token', package: 'my-pkg', formData: { @@ -32,13 +30,13 @@ const makeAccessHtml = () => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('linkTrustedPublisher', () => { test('builds correct form body for GitHub publisher', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -46,7 +44,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'myorg', repository: 'myrepo', @@ -64,7 +62,7 @@ describe('linkTrustedPublisher', () => { test('builds correct form body for GitLab publisher', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -72,7 +70,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'gitlab', namespace: 'my-group', project: 'my-project', @@ -89,7 +87,7 @@ describe('linkTrustedPublisher', () => { test('includes environment when provided', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -97,7 +95,7 @@ describe('linkTrustedPublisher', () => { return mockResponse(200, makeAccessHtml()); }); - await linkTrustedPublisher(context, 'my-pkg', { + await linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'org', repository: 'repo', @@ -109,14 +107,14 @@ describe('linkTrustedPublisher', () => { }); test('throws on failure', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(linkTrustedPublisher(context, 'my-pkg', { + await expect(linkTrustedPublisher(client, 'my-pkg', { type: 'github', owner: 'org', repository: 'repo', diff --git a/tests/specs/api/list-packages.ts b/tests/specs/api/list-packages.ts index 5dbfd66..5cad441 100644 --- a/tests/specs/api/list-packages.ts +++ b/tests/specs/api/list-packages.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { listPackages } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -36,7 +34,7 @@ const makePackage = (name: string, overrides: Record = {}) => ( describe('listPackages', () => { test('fetches single page of packages', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ packagesCounts: { all: 2 }, @@ -44,7 +42,7 @@ describe('listPackages', () => { }), )); - const packages = await listPackages(context); + const packages = await listPackages(client); expect(packages).toHaveLength(2); expect(packages[0].name).toBe('pkg-a'); expect(packages[0].isPrivate).toBe(false); @@ -54,7 +52,7 @@ describe('listPackages', () => { test('paginates across multiple pages', async () => { let callCount = 0; - const context = mockContext(async (url) => { + const client = mockClient(async (url) => { callCount += 1; const objects = url.includes('page=0') ? [makePackage('a')] @@ -68,13 +66,13 @@ describe('listPackages', () => { ); }); - const packages = await listPackages(context); + const packages = await listPackages(client); expect(packages).toHaveLength(2); expect(callCount).toBe(2); }); test('maps all fields correctly', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ packagesCounts: { all: 1 }, @@ -95,7 +93,7 @@ describe('listPackages', () => { }), )); - const [package_] = await listPackages(context); + const [package_] = await listPackages(client); expect(package_.name).toBe('my-pkg'); expect(package_.version).toBe('3.2.1'); expect(package_.isPrivate).toBe(true); @@ -107,9 +105,36 @@ describe('listPackages', () => { expect(package_.updatedRel).toBe('today'); }); + test('handles missing fields with fallback defaults', async () => { + const client = mockClient(async () => mockResponse( + 200, + JSON.stringify({ + packagesCounts: { all: 1 }, + packages: { + objects: [{ + name: 'empty-pkg', + // All other fields missing — simulates placeholder/unpublished package + }], + }, + }), + )); + + const [package_] = await listPackages(client); + expect(package_.name).toBe('empty-pkg'); + expect(package_.version).toBe(''); + expect(package_.description).toBe(''); + expect(package_.isPrivate).toBe(false); + expect(package_.isHighImpact).toBe(false); + expect(package_.lastPublishTs).toBe(0); + expect(package_.lastPublishRel).toBe(''); + expect(package_.publisher).toBe(''); + expect(package_.createdRel).toBe(''); + expect(package_.updatedRel).toBe(''); + }); + test('throws on non-200 response', async () => { - const context = mockContext(async () => mockResponse(500, 'error')); + const client = mockClient(async () => mockResponse(500, 'error')); - await expect(listPackages(context)).rejects.toThrow('Failed to fetch packages'); + await expect(listPackages(client)).rejects.toThrow('Failed to fetch packages'); }); }); diff --git a/tests/specs/api/set-publishing-access.ts b/tests/specs/api/set-publishing-access.ts index 52fccf6..270a59b 100644 --- a/tests/specs/api/set-publishing-access.ts +++ b/tests/specs/api/set-publishing-access.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { setPublishingAccess } from '../../../src/index.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,7 +17,7 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ }); const makeAccessHtml = (csrftoken = 'csrf-123') => { - const context = { + const client = { csrftoken, package: 'my-pkg', packageVersion: { repository: 'https://github.com/user/repo' }, @@ -33,13 +31,13 @@ const makeAccessHtml = (csrftoken = 'csrf-123') => { oidcConnections: [], maintainers: [], }; - return ``; + return ``; }; describe('setPublishingAccess', () => { test('submits publishingAccess with CSRF token', async () => { let postBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { postBody = init.body?.toString(); return mockResponse(302, ''); @@ -47,14 +45,14 @@ describe('setPublishingAccess', () => { return mockResponse(200, makeAccessHtml('my-csrf')); }); - await setPublishingAccess(context, 'my-pkg', 'tfa-always-required'); + await setPublishingAccess(client, 'my-pkg', 'tfa-always-required'); expect(postBody).toContain('publishingAccess=tfa-always-required'); expect(postBody).toContain('csrftoken=my-csrf'); }); test('handles OTP escalation on POST', async () => { let callIndex = 0; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET access page (from authenticatedGet via getAccessPageWithCsrf) if (callIndex === 1) { @@ -68,19 +66,19 @@ describe('setPublishingAccess', () => { return mockResponse(302, ''); }); - await setPublishingAccess(context, 'my-pkg', 'tfa-always-required'); + await setPublishingAccess(client, 'my-pkg', 'tfa-always-required'); expect(callIndex).toBe(3); }); test('throws on failure status', async () => { - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { return mockResponse(500, 'error'); } return mockResponse(200, makeAccessHtml()); }); - await expect(setPublishingAccess(context, 'my-pkg', 'tfa-always-required')) + await expect(setPublishingAccess(client, 'my-pkg', 'tfa-always-required')) .rejects.toThrow('Failed to set publishing access'); }); }); diff --git a/tests/specs/context/perform-login.ts b/tests/specs/context/perform-login.ts index 011a9ec..bdbe7e3 100644 --- a/tests/specs/context/perform-login.ts +++ b/tests/specs/context/perform-login.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; import { performLogin } from '../../../src/context/login.ts'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -19,12 +17,12 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('performLogin', () => { test('returns skipped when already logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'alice' } }), )); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -36,7 +34,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check if (callIndex === 1) { @@ -53,7 +51,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -65,7 +63,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check — non-200 if (callIndex === 1) { @@ -82,7 +80,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -92,7 +90,7 @@ describe('performLogin', () => { test('throws when login page fetch fails', async () => { let callIndex = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -100,7 +98,7 @@ describe('performLogin', () => { return mockResponse(500, 'error'); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'pass', })).rejects.toThrow('Failed to fetch login page'); @@ -117,7 +115,7 @@ describe('performLogin', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: isLoggedIn check if (callIndex === 1) { @@ -142,7 +140,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - const result = await performLogin(context, { + const result = await performLogin(client, { username: 'alice', password: 'pass', }); @@ -162,7 +160,7 @@ describe('performLogin', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -183,7 +181,7 @@ describe('performLogin', () => { return mockResponse(200, ''); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'pass', })).rejects.toThrow('OTP submission failed'); @@ -193,7 +191,7 @@ describe('performLogin', () => { let callIndex = 0; const loginHtml = '
'; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, JSON.stringify({ user: null })); @@ -205,22 +203,22 @@ describe('performLogin', () => { return mockResponse(401, 'unauthorized'); }); - await expect(performLogin(context, { + await expect(performLogin(client, { username: 'alice', password: 'wrong', })).rejects.toThrow('Login failed'); }); test('caches username when already logged in', async () => { - const context = mockContext(async () => mockResponse( + const client = mockClient(async () => mockResponse( 200, JSON.stringify({ user: { name: 'alice' } }), )); - await performLogin(context, { + await performLogin(client, { username: 'alice', password: 'pass', }); - expect(context.cachedUsername).toBe('alice'); + expect(client.cachedUsername).toBe('alice'); }); }); diff --git a/tests/specs/create-client-fetch.ts b/tests/specs/create-client-fetch.ts new file mode 100644 index 0000000..cf03c87 --- /dev/null +++ b/tests/specs/create-client-fetch.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { describe, test, expect } from 'manten'; +import { createClient } from '../../src/index.ts'; + +/** + * Tests that createClient's internal fetch resolves URLs correctly + * and produces a working fetch pipeline. + * + * These tests create a real client and make real requests to npm + * (unauthenticated GETs only — no credentials needed). + */ +describe('createClient fetch', () => { + const sessionFile = path.join(os.tmpdir(), `.npm-pkg-settings-url-test-${Date.now()}.json`); + + test('resolves relative path to npm URL', async () => { + const client = createClient({ + otpSecret: '', + sessionFile, + }); + + // getUsername fetches '' (empty path) → should resolve to https://www.npmjs.com/ + // The response should be valid JSON (npm homepage data), not a 404 or redirect + try { + await client.getUsername(); + } catch (error) { + // "Not logged in" means the fetch resolved to the right URL and got a valid response + expect((error as Error).message).toContain('Not logged in'); + } + }); + + test('cleanup', async () => { + await fs.rm(sessionFile, { force: true }); + }); +}); diff --git a/tests/specs/curl-fetch/curl-fetch.ts b/tests/specs/curl-fetch/curl-fetch.ts deleted file mode 100644 index 1e1401d..0000000 --- a/tests/specs/curl-fetch/curl-fetch.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockResponse = 'HTTP/1.1 200 OK\r\n\r\n'; - -const captureSpawn = () => { - const calls: { command: string; - arguments_: string[]; }[] = []; - const spawn: SpawnFunction = async (command, arguments_) => { - calls.push({ - command, - arguments_, - }); - return { stdout: mockResponse }; - }; - return { - spawn, - calls, - }; -}; - -describe('createFetch', () => { - test('calls curl_chrome145', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].command).toBe('curl_chrome145'); - }); - - test('passes URL as last argument', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com/path'); - expect(calls[0].arguments_.at(-1)).toBe('https://example.com/path'); - }); - - test('defaults to GET method', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].arguments_).toContain('GET'); - }); - - test('sets POST method', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { method: 'POST' }); - expect(calls[0].arguments_).toContain('POST'); - }); - - test('adds cookie file args when provided', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ - sessionFile: '/tmp/jar.txt', - spawn, - }); - await fetch('https://example.com'); - expect(calls[0].arguments_).toContain('-b'); - expect(calls[0].arguments_).toContain('/tmp/jar.txt'); - expect(calls[0].arguments_).toContain('-c'); - }); - - test('omits cookie args when no sessionFile', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com'); - expect(calls[0].arguments_).not.toContain('-b'); - expect(calls[0].arguments_).not.toContain('-c'); - }); - - test('adds -L for redirect follow', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { redirect: 'follow' }); - expect(calls[0].arguments_).toContain('-L'); - }); - - test('adds --max-redirs 0 for redirect manual', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { redirect: 'manual' }); - expect(calls[0].arguments_).toContain('--max-redirs'); - expect(calls[0].arguments_).toContain('0'); - }); - - test('passes custom headers', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - headers: { - 'x-custom': 'value', - authorization: 'Bearer token', - }, - }); - expect(calls[0].arguments_).toContain('x-custom: value'); - expect(calls[0].arguments_).toContain('authorization: Bearer token'); - }); - - test('passes URLSearchParams body with content-type', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - method: 'POST', - body: new URLSearchParams({ user: 'alice' }), - }); - expect(calls[0].arguments_).toContain('-d'); - expect(calls[0].arguments_).toContain('user=alice'); - expect(calls[0].arguments_).toContain('content-type: application/x-www-form-urlencoded'); - }); - - test('passes string body', async () => { - const { spawn, calls } = captureSpawn(); - const fetch = createFetch({ spawn }); - await fetch('https://example.com', { - method: 'POST', - body: 'raw-body', - }); - expect(calls[0].arguments_).toContain('-d'); - expect(calls[0].arguments_).toContain('raw-body'); - }); -}); diff --git a/tests/specs/curl-fetch/parse-headers.ts b/tests/specs/curl-fetch/parse-headers.ts deleted file mode 100644 index 9dc89e7..0000000 --- a/tests/specs/curl-fetch/parse-headers.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockSpawn = (raw: string): SpawnFunction => async () => ({ stdout: raw }); - -describe('response header parsing', () => { - test('parses standard HTTP headers', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nSet-Cookie: sid=abc\r\n\r\nbody'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['content-type']).toBe('text/html'); - expect(response.headers['set-cookie']).toBe('sid=abc'); - }); - - test('lowercases header names', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nX-Custom-Header: value\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['x-custom-header']).toBe('value'); - }); - - test('handles colons in header values', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nLocation: https://example.com:443/path\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers.location).toBe('https://example.com:443/path'); - }); - - test('skips status line (no colon-space separator)', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['content-type']).toBe('text/html'); - expect(Object.keys(response.headers)).toHaveLength(1); - }); -}); diff --git a/tests/specs/curl-fetch/parse-raw-response.ts b/tests/specs/curl-fetch/parse-raw-response.ts deleted file mode 100644 index 807ff98..0000000 --- a/tests/specs/curl-fetch/parse-raw-response.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, test, expect } from 'manten'; -import { createFetch, type SpawnFunction } from '../../../src/curl-fetch.ts'; - -const mockSpawn = (raw: string): SpawnFunction => async () => ({ stdout: raw }); - -describe('response parsing', () => { - test('splits headers and body on double CRLF', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nhello'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(200); - expect(response.ok).toBe(true); - expect(response.statusText).toBe('OK'); - expect(response.headers['content-type']).toBe('text/html'); - expect(await response.text()).toBe('hello'); - }); - - test('parses 302 redirect', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/2 302 Found\r\nLocation: /otp\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(302); - expect(response.ok).toBe(false); - expect(response.headers.location).toBe('/otp'); - }); - - test('parses 403 status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 403 Forbidden\r\n\r\nblocked'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(403); - expect(response.statusText).toBe('Forbidden'); - expect(await response.text()).toBe('blocked'); - }); - - test('returns status 0 for malformed input', async () => { - const fetch = createFetch({ - spawn: mockSpawn('garbage\r\n\r\nbody'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - }); - - test('handles missing header-body separator', async () => { - const fetch = createFetch({ - spawn: mockSpawn('raw output with no separator'), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - expect(response.statusText).toBe(''); - expect(await response.text()).toBe('raw output with no separator'); - }); - - test('handles empty output', async () => { - const fetch = createFetch({ - spawn: mockSpawn(''), - }); - const response = await fetch('http://example.com'); - expect(response.status).toBe(0); - expect(await response.text()).toBe(''); - }); - - test('json() parses body as JSON', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\n\r\n{"key":"value"}'), - }); - const response = await fetch('http://example.com'); - expect(await response.json()).toEqual({ key: 'value' }); - }); - - test('json() throws on invalid JSON', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\n\r\n{invalid json}'), - }); - const response = await fetch('http://example.com'); - await expect(response.json()).rejects.toThrow(); - }); - - test('ok is true for 2xx status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 201 Created\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.ok).toBe(true); - expect(response.status).toBe(201); - }); - - test('ok is false for non-2xx status', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 500 Internal Server Error\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.ok).toBe(false); - }); - - test('handles duplicate headers (last value wins)', async () => { - const fetch = createFetch({ - spawn: mockSpawn('HTTP/1.1 200 OK\r\nX-Val: first\r\nX-Val: second\r\n\r\n'), - }); - const response = await fetch('http://example.com'); - expect(response.headers['x-val']).toBe('second'); - }); -}); diff --git a/tests/specs/smoke/cloudflare-bypass.ts b/tests/specs/smoke/cloudflare-bypass.ts new file mode 100644 index 0000000..b281be1 --- /dev/null +++ b/tests/specs/smoke/cloudflare-bypass.ts @@ -0,0 +1,63 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { describe, test, expect } from 'manten'; +import { createClient } from '../../../src/index.ts'; + +/** + * Smoke tests: verify the real createClient pipeline bypasses + * Cloudflare's bot detection on npm. + * + * Cloudflare fingerprints TLS handshakes (JA3/JA4) and HTTP/2 framing + * to distinguish real browsers from automated clients. POST requests + * to sensitive endpoints (e.g. /login) face stricter checks than GETs. + * + * These tests go through createClient (not bare Impit) to catch + * misconfiguration of the browser fingerprint or cookie jar. + */ +describe('Cloudflare bypass', () => { + const sessionFile = path.join(os.tmpdir(), `.npm-pkg-settings-test-${Date.now()}.json`); + + test('GET request is not blocked', async () => { + const client = createClient({ + otpSecret: '', + sessionFile, + }); + + // getUsername fetches GET / with x-spiferack header + // Without a session it won't find a user, but the request should reach npm (not be challenged) + try { + await client.getUsername(); + } catch (error) { + // "Not logged in" = npm responded with real data (not blocked) + // "Failed to fetch homepage" with status 403 = Cloudflare blocked us + expect((error as Error).message).toContain('Not logged in'); + } + }); + + test('POST request is not blocked', async () => { + const client = createClient({ + otpSecret: '', + username: 'cloudflare-bypass-test', + password: 'not-a-real-password', + sessionFile, + }); + + // login() POSTs to /login — with fake credentials it should fail with + // a login error, NOT a Cloudflare 403 challenge + try { + await client.login(); + } catch (error) { + const { message } = (error as Error); + // Any of these mean npm processed the request (not blocked by Cloudflare) + const reachedNpm = message.includes('Login failed') + || message.includes('Failed to fetch login page') + || message.includes('OTP'); + expect(reachedNpm).toBe(true); + } + }); + + test('cleanup', async () => { + await fs.rm(sessionFile, { force: true }); + }); +}); diff --git a/tests/specs/utils/authenticated-get.ts b/tests/specs/utils/authenticated-get.ts index daa9b85..ab8c3b9 100644 --- a/tests/specs/utils/authenticated-get.ts +++ b/tests/specs/utils/authenticated-get.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { authenticatedGet } from '../../../src/utils/authenticated-get.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '123456', @@ -20,17 +18,17 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('authenticatedGet', () => { test('returns body directly when no escalation', async () => { const validPage = ''; - const context = mockContext(async () => mockResponse(200, validPage)); + const client = mockClient(async () => mockResponse(200, validPage)); - const result = await authenticatedGet(context, 'some/path'); + const result = await authenticatedGet(client, 'some/path'); expect(result.status).toBe(200); expect(result.body).toBe(validPage); }); test('returns non-200 status without escalation', async () => { - const context = mockContext(async () => mockResponse(429, 'rate limited')); + const client = mockClient(async () => mockResponse(429, 'rate limited')); - const result = await authenticatedGet(context, 'some/path'); + const result = await authenticatedGet(client, 'some/path'); expect(result.status).toBe(429); expect(result.body).toBe('rate limited'); }); @@ -38,7 +36,7 @@ describe('authenticatedGet', () => { test('follows 302 redirect', async () => { let callIndex = 0; const redirectedPage = ''; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(302, '', { location: '/redirected' }); @@ -46,7 +44,7 @@ describe('authenticatedGet', () => { return mockResponse(200, redirectedPage); }); - const result = await authenticatedGet(context, 'original'); + const result = await authenticatedGet(client, 'original'); expect(result.status).toBe(200); expect(result.body).toBe(redirectedPage); }); @@ -61,7 +59,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; // 1: GET page → OTP escalation if (callIndex === 1) { @@ -75,7 +73,7 @@ describe('authenticatedGet', () => { return mockResponse(200, 'actual content'); }); - const result = await authenticatedGet(context, 'protected/path'); + const result = await authenticatedGet(client, 'protected/path'); expect(result.status).toBe(200); expect(result.body).toBe('actual content'); expect(callIndex).toBe(3); @@ -91,7 +89,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { if (init?.method === 'POST') { capturedBody = init.body?.toString(); return mockResponse(200, 'done'); @@ -99,7 +97,7 @@ describe('authenticatedGet', () => { return mockResponse(200, otpPage); }); - await authenticatedGet(context, 'path'); + await authenticatedGet(client, 'path'); expect(capturedBody).toContain('otp=123456'); expect(capturedBody).toContain('csrftoken=my-csrf'); expect(capturedBody).toContain('formName=totp'); @@ -115,7 +113,7 @@ describe('authenticatedGet', () => { One-time Password `; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, otpPage); @@ -124,7 +122,7 @@ describe('authenticatedGet', () => { return mockResponse(200, 'otp result'); }); - const result = await authenticatedGet(context, 'path'); + const result = await authenticatedGet(client, 'path'); expect(result.status).toBe(200); expect(result.body).toBe('otp result'); }); @@ -133,7 +131,7 @@ describe('authenticatedGet', () => { let callIndex = 0; const loginPage = 'npm | Sign In
Sign In
'; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; if (callIndex === 1) { return mockResponse(302, '', { location: '/login?next=%2Fpackage%2Ftsx%2Faccess' }); @@ -141,7 +139,7 @@ describe('authenticatedGet', () => { return mockResponse(200, loginPage); }); - await expect(authenticatedGet(context, 'package/tsx/access')) + await expect(authenticatedGet(client, 'package/tsx/access')) .rejects.toThrow('Session expired'); }); }); diff --git a/tests/specs/utils/generate-otp.ts b/tests/specs/utils/generate-otp.ts index 54f5ddb..f1fd0a4 100644 --- a/tests/specs/utils/generate-otp.ts +++ b/tests/specs/utils/generate-otp.ts @@ -1,11 +1,11 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { generateOtp } from '../../../src/utils/generate-otp.ts'; describe('generateOtp', () => { test('calls otpGenerator with otpSecret', async () => { let capturedSecret = ''; - const context: NpmContext = { + const client: NpmInternalClient = { fetch: async () => { throw new Error('should not fetch'); }, otpSecret: 'MY_SECRET', otpGenerator: async (secret) => { @@ -14,7 +14,7 @@ describe('generateOtp', () => { }, }; - const otp = await generateOtp(context); + const otp = await generateOtp(client); expect(otp).toBe('123456'); expect(capturedSecret).toBe('MY_SECRET'); }); diff --git a/tests/specs/utils/npm-fetch.ts b/tests/specs/utils/npm-fetch.ts deleted file mode 100644 index 071e1cf..0000000 --- a/tests/specs/utils/npm-fetch.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; -import { npmFetch } from '../../../src/utils/npm-fetch.ts'; - -const mockResponse = (status: number, body: string) => ({ - status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers: {} as Record, - text: async () => body, - json: async () => JSON.parse(body), -}); - -describe('npmFetch', () => { - test('prepends base URL for relative paths', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'login'); - expect(capturedUrl).toBe('https://www.npmjs.com/login'); - }); - - test('passes full URL unchanged for absolute paths', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'https://custom.example.com/path'); - expect(capturedUrl).toBe('https://custom.example.com/path'); - }); - - test('forwards init options to fetch', async () => { - let capturedInit: unknown; - const context: NpmContext = { - fetch: async (_url, init) => { - capturedInit = init; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, 'test', { - method: 'POST', - headers: { 'x-custom': 'value' }, - }); - - expect(capturedInit).toEqual({ - method: 'POST', - headers: { 'x-custom': 'value' }, - }); - }); - - test('handles empty path for root URL', async () => { - let capturedUrl = ''; - const context: NpmContext = { - fetch: async (url) => { - capturedUrl = url; - return mockResponse(200, ''); - }, - otpSecret: '', - otpGenerator: async () => '', - }; - - await npmFetch(context, ''); - expect(capturedUrl).toBe('https://www.npmjs.com/'); - }); -}); diff --git a/tests/specs/utils/submit-with-otp.ts b/tests/specs/utils/submit-with-otp.ts index 35e199d..0e91cb3 100644 --- a/tests/specs/utils/submit-with-otp.ts +++ b/tests/specs/utils/submit-with-otp.ts @@ -1,17 +1,15 @@ import { describe, test, expect } from 'manten'; -import type { NpmContext } from '../../../src/types.ts'; +import type { NpmInternalClient } from '../../../src/types.ts'; import { submitWithOtp } from '../../../src/utils/submit-with-otp.ts'; const mockResponse = (status: number, body: string, headers: Record = {}) => ({ status, - statusText: 'OK', - ok: status >= 200 && status < 300, - headers, + headers: { get: (name: string) => headers[name.toLowerCase()] ?? null }, text: async () => body, json: async () => JSON.parse(body), }); -const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ +const mockClient = (handler: NpmInternalClient['fetch']): NpmInternalClient => ({ fetch: handler, otpSecret: 'test', otpGenerator: async () => '654321', @@ -20,13 +18,13 @@ const mockContext = (handler: NpmContext['fetch']): NpmContext => ({ describe('submitWithOtp', () => { test('posts body and returns response when no escalation', async () => { let capturedBody: string | undefined; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { capturedBody = init?.body?.toString(); return mockResponse(302, ''); }); const result = await submitWithOtp( - context, + client, 'some/path', new URLSearchParams({ key: 'value', @@ -48,7 +46,7 @@ describe('submitWithOtp', () => { One-time Password `; - const context = mockContext(async () => { + const client = mockClient(async () => { callIndex += 1; // 1: POST form → escalation page if (callIndex === 1) { @@ -59,7 +57,7 @@ describe('submitWithOtp', () => { }); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ data: 'test' }), ); @@ -79,7 +77,7 @@ describe('submitWithOtp', () => { One-time Password `; - const context = mockContext(async (_url, init) => { + const client = mockClient(async (_url, init) => { callIndex += 1; if (callIndex === 1) { return mockResponse(200, otpPage); @@ -88,17 +86,17 @@ describe('submitWithOtp', () => { return mockResponse(302, ''); }); - await submitWithOtp(context, 'path', new URLSearchParams({ x: '1' })); + await submitWithOtp(client, 'path', new URLSearchParams({ x: '1' })); expect(otpBody).toContain('otp=654321'); expect(otpBody).toContain('csrftoken=esc-csrf'); expect(otpBody).toContain('formName=totp'); }); test('returns error status without escalation', async () => { - const context = mockContext(async () => mockResponse(500, 'server error')); + const client = mockClient(async () => mockResponse(500, 'server error')); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ x: '1' }), ); @@ -109,13 +107,13 @@ describe('submitWithOtp', () => { test('does not escalate on non-200 with OTP text', async () => { let callCount = 0; - const context = mockContext(async () => { + const client = mockClient(async () => { callCount += 1; return mockResponse(403, 'One-time Password forbidden'); }); const result = await submitWithOtp( - context, + client, 'path', new URLSearchParams({ x: '1' }), );