From e4bb31beb1ef2aedb2a478f56ca03f93c64bce48 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:33:25 +0900 Subject: [PATCH 1/7] feat(core): add isOfficialNpmRegistry helper for login dispatch --- packages/core/src/registry/npm.ts | 6 ++++++ packages/core/tests/unit/registry/npm.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/core/src/registry/npm.ts b/packages/core/src/registry/npm.ts index cda11b0c..864a03eb 100644 --- a/packages/core/src/registry/npm.ts +++ b/packages/core/src/registry/npm.ts @@ -21,6 +21,8 @@ class NpmError extends AbstractError { name = "npm Error"; } +const NPM_OFFICIAL_REGISTRY = "https://registry.npmjs.org"; + function validateNpmLoginUrl(rawUrl: string): string | null { let parsed: URL; try { @@ -487,6 +489,10 @@ export class NpmPackageRegistry extends PackageRegistry { }; } + private isOfficialNpmRegistry(): boolean { + return normalizeRegistryUrl(this.registry).includes("registry.npmjs.org"); + } + private isProvenanceError(error: unknown): boolean { if (!(error instanceof NonZeroExitError)) return false; const stderr = error.output.stderr; diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index b76aa922..22dc4002 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -763,6 +763,23 @@ describe("NpmPackageRegistry checkAvailability()", () => { return { FreshNpmRegistry, openUrl, spawnInteractive }; } + describe("isOfficialNpmRegistry()", () => { + it("returns true for default registry", () => { + const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH); + expect(registry["isOfficialNpmRegistry"]()).toBe(true); + }); + + it("returns true for registry with trailing slash", () => { + const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH, "https://registry.npmjs.org/"); + expect(registry["isOfficialNpmRegistry"]()).toBe(true); + }); + + it("returns false for private registry", () => { + const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH, "https://npm.mycompany.com"); + expect(registry["isOfficialNpmRegistry"]()).toBe(false); + }); + }); + describe("N1: login check", () => { it("throws when not logged in in CI mode", async () => { vi.spyOn(registry, "isLoggedIn").mockResolvedValue(false); From 94f4300a4e8891a799c1c550a6f08fbd45190626 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:35:23 +0900 Subject: [PATCH 2/7] feat(core): implement direct web login for official npm registry --- packages/core/src/registry/npm.ts | 96 ++++++++++++ packages/core/tests/unit/registry/npm.test.ts | 140 ++++++++++++++++-- 2 files changed, 226 insertions(+), 10 deletions(-) diff --git a/packages/core/src/registry/npm.ts b/packages/core/src/registry/npm.ts index 864a03eb..1820731f 100644 --- a/packages/core/src/registry/npm.ts +++ b/packages/core/src/registry/npm.ts @@ -531,10 +531,106 @@ export class NpmPackageRegistry extends PackageRegistry { return new NpmError("Failed to publish to npm", { cause: error }); } + private async runDirectWebLogin( + // biome-ignore lint/suspicious/noExplicitAny: listr2 TaskWrapper type is complex + task: any, + ): Promise { + task.output = "Launching npm login..."; + + const isValidUrl = (url: string): boolean => { + try { + return /^https?:$/.test(new URL(url).protocol); + } catch { + return false; + } + }; + + // Step 1: POST to login endpoint + const loginEndpoint = `${NPM_OFFICIAL_REGISTRY}/-/v1/login`; + const res = await fetch(loginEndpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + if (!res.ok) { + throw new NpmError( + `npm web login initiation failed (HTTP ${res.status})`, + ); + } + + const body = (await res.json()) as { + loginUrl?: string; + doneUrl?: string; + }; + + const { loginUrl, doneUrl } = body; + if ( + !loginUrl || + !doneUrl || + !isValidUrl(loginUrl) || + !isValidUrl(doneUrl) + ) { + throw new NpmError( + "npm web login response missing valid loginUrl or doneUrl", + ); + } + + // Step 2: Open browser and show URL + task.output = `Login at: ${color.cyan(loginUrl)}`; + const { openUrl } = await import("../utils/open-url.js"); + void openUrl(loginUrl).catch(() => {}); + + // Step 3: Poll doneUrl + while (true) { + const pollRes = await fetch(doneUrl); + const pollBody = (await pollRes.json()) as { token?: string }; + + if (pollRes.status === 200) { + if (!pollBody.token) { + throw new NpmError("npm web login completed but no token received"); + } + + // Step 4: Save token + try { + await exec( + "npm", + [ + "config", + "set", + "//registry.npmjs.org/:_authToken", + pollBody.token, + "--location=user", + ], + { throwOnError: true }, + ); + } catch (error) { + throw new NpmError("Failed to save npm auth token", { cause: error }); + } + return; + } + + if (pollRes.status === 202) { + const retryAfter = Number(pollRes.headers.get("retry-after")) * 1000; + const delay = retryAfter > 0 ? retryAfter : 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + + throw new NpmError( + `npm web login polling failed (HTTP ${pollRes.status})`, + ); + } + } + private async runInteractiveLogin( // biome-ignore lint/suspicious/noExplicitAny: listr2 TaskWrapper type is complex task: any, ): Promise { + if (this.isOfficialNpmRegistry()) { + return this.runDirectWebLogin(task); + } + task.output = "Launching npm login..."; const [{ spawnInteractive }, { openUrl }] = await Promise.all([ diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index 22dc4002..be84b11c 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -770,16 +770,104 @@ describe("NpmPackageRegistry checkAvailability()", () => { }); it("returns true for registry with trailing slash", () => { - const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH, "https://registry.npmjs.org/"); + const registry = new NpmPackageRegistry( + "my-package", + FIXTURE_PATH, + "https://registry.npmjs.org/", + ); expect(registry["isOfficialNpmRegistry"]()).toBe(true); }); it("returns false for private registry", () => { - const registry = new NpmPackageRegistry("my-package", FIXTURE_PATH, "https://npm.mycompany.com"); + const registry = new NpmPackageRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); expect(registry["isOfficialNpmRegistry"]()).toBe(false); }); }); + describe("direct web login (official npm)", () => { + function makeDoneResponse(token: string) { + return new Response(JSON.stringify({ token }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + function make202Response(retryAfter = "1") { + return new Response(JSON.stringify({}), { + status: 202, + headers: { + "retry-after": retryAfter, + "content-type": "application/json", + }, + }); + } + + function makeLoginResponse(loginUrl: string, doneUrl: string) { + return new Response(JSON.stringify({ loginUrl, doneUrl }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + it("completes direct web login: POST → poll 202 → poll 200 → save token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const loginUrl = "https://www.npmjs.com/auth/cli/test-uuid"; + const doneUrl = "https://www.npmjs.com/-/v1/login/test-uuid/done"; + + mockedFetch + .mockResolvedValueOnce(makeLoginResponse(loginUrl, doneUrl)) + .mockResolvedValueOnce(make202Response("0")) + .mockResolvedValueOnce(makeDoneResponse("npm_test-token-123")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + // Verify POST to /-/v1/login + expect(mockedFetch).toHaveBeenCalledWith( + "https://registry.npmjs.org/-/v1/login", + expect.objectContaining({ method: "POST" }), + ); + // Verify doneUrl polled + expect(mockedFetch).toHaveBeenCalledWith(doneUrl); + // Verify browser opened with loginUrl + expect(openUrl).toHaveBeenCalledWith(loginUrl); + // Verify token saved + expect(mockedExec).toHaveBeenCalledWith( + "npm", + [ + "config", + "set", + "//registry.npmjs.org/:_authToken", + "npm_test-token-123", + "--location=user", + ], + expect.objectContaining({ throwOnError: true }), + ); + // Verify loginUrl shown in task output + expect(task.output).toContain(loginUrl); + }); + }); + describe("N1: login check", () => { it("throws when not logged in in CI mode", async () => { vi.spyOn(registry, "isLoggedIn").mockResolvedValue(false); @@ -797,7 +885,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/login?next=/login/cli/abc-123\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); @@ -825,7 +917,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/auth/cli/xyz-789\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(true); @@ -849,8 +945,16 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/login?next=/login/cli/shared-123\n", ]), ); - const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); - const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + const firstRegistry = new FreshNpmRegistry( + "pkg-a", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); + const secondRegistry = new FreshNpmRegistry( + "pkg-b", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); for (const current of [firstRegistry, secondRegistry]) { vi.spyOn(current, "isLoggedIn") @@ -883,7 +987,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { FreshNpmRegistry } = await importFreshRegistryWithMocks( makeChild([], [], 1), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); await expect( @@ -895,7 +1003,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { FreshNpmRegistry } = await importFreshRegistryWithMocks( makeChild(["Logged in on https://registry.npmjs.org/.\n"]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); await expect( @@ -929,7 +1041,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { const { NpmPackageRegistry: FreshNpmRegistry } = await import( "../../../src/registry/npm.js" ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(false) @@ -961,7 +1077,11 @@ describe("NpmPackageRegistry checkAvailability()", () => { "https://www.npmjs.com/auth/cli/still-not-logged-in\n", ]), ); - const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); vi.spyOn(freshRegistry, "isLoggedIn") .mockResolvedValueOnce(false) .mockResolvedValueOnce(false); From 9a857396f421f1191e0bb067b839dd2c96433296 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:40:23 +0900 Subject: [PATCH 3/7] test(core): add error case tests for direct npm web login --- packages/core/tests/unit/registry/npm.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index be84b11c..8d3ade50 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -866,6 +866,196 @@ describe("NpmPackageRegistry checkAvailability()", () => { // Verify loginUrl shown in task output expect(task.output).toContain(loginUrl); }); + + it("throws when POST response is missing loginUrl", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ doneUrl: "https://example.com/done" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login response missing valid loginUrl or doneUrl"); + }); + + it("throws when POST response has invalid URL", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ loginUrl: "not-a-url", doneUrl: "also-not-a-url" }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login response missing valid loginUrl or doneUrl"); + }); + + it("throws when polling returns 200 without token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { status: 200 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login completed but no token received"); + }); + + it("throws on unexpected polling status", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login polling failed (HTTP 500)"); + }); + + it("throws when POST to login endpoint fails", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response("Not Found", { status: 404 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login initiation failed (HTTP 404)"); + }); + + it("uses 1 second default when Retry-After header is absent", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({}), { status: 202 }), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const delayCall = setTimeoutSpy.mock.calls.find( + ([, ms]) => ms === 1000, + ); + expect(delayCall).toBeDefined(); + + setTimeoutSpy.mockRestore(); + }); + + it("succeeds even when browser open fails", async () => { + const openUrl = vi.fn().mockRejectedValue(new Error("no browser")); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).resolves.toBeUndefined(); + }); + + it("throws when npm config set fails", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockRejectedValue(new Error("permission denied")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Failed to save npm auth token"); + }); }); describe("N1: login check", () => { From 1a5d14b4b6a648ea7b49e90cd32e48998c83cb13 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:40:44 +0900 Subject: [PATCH 4/7] test(core): add fallback and dedup tests for npm login --- packages/core/tests/unit/registry/npm.test.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index 8d3ade50..bc84ac0e 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -1056,6 +1056,81 @@ describe("NpmPackageRegistry checkAvailability()", () => { freshRegistry.checkAvailability(makeTask(), makeCtx(true)), ).rejects.toThrow("Failed to save npm auth token"); }); + + it("uses spawnInteractive fallback for private registries", async () => { + const child = makeChild([ + "Login at:\n", + "https://www.npmjs.com/login?next=/login/cli/abc-123\n", + ]); + + const { FreshNpmRegistry, openUrl, spawnInteractive } = + await importFreshRegistryWithMocks(child); + + const freshRegistry = new FreshNpmRegistry( + "my-package", + FIXTURE_PATH, + "https://npm.mycompany.com", + ); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(spawnInteractive).toHaveBeenCalledWith(["npm", "login"]); + // fetch should NOT have been called with /-/v1/login + expect(mockedFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/-/v1/login"), + expect.anything(), + ); + }); + + it("deduplicates concurrent direct web login attempts", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); + const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + + for (const current of [firstRegistry, secondRegistry]) { + vi.spyOn(current, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(current, "isPublished").mockResolvedValue(true); + vi.spyOn(current, "hasPermission").mockResolvedValue(true); + } + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const ctx = makeCtx(true); + await expect( + Promise.all([ + firstRegistry.checkAvailability(makeTask(), ctx), + secondRegistry.checkAvailability(makeTask(), ctx), + ]), + ).resolves.toEqual([undefined, undefined]); + + // POST to /-/v1/login should only be called once + const loginCalls = mockedFetch.mock.calls.filter( + ([url, opts]) => + typeof url === "string" && + url.endsWith("/-/v1/login") && + (opts as any)?.method === "POST", + ); + expect(loginCalls).toHaveLength(1); + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + }); }); describe("N1: login check", () => { From 325aa78dd7e2bd591ce079604f98bca084ebca66 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:43:12 +0900 Subject: [PATCH 5/7] fix(core): fix type error in isOfficialNpmRegistry and format --- packages/core/src/registry/npm.ts | 1 + packages/core/tests/unit/registry/npm.test.ts | 46 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/core/src/registry/npm.ts b/packages/core/src/registry/npm.ts index 1820731f..da5853eb 100644 --- a/packages/core/src/registry/npm.ts +++ b/packages/core/src/registry/npm.ts @@ -490,6 +490,7 @@ export class NpmPackageRegistry extends PackageRegistry { } private isOfficialNpmRegistry(): boolean { + if (!this.registry) return true; return normalizeRegistryUrl(this.registry).includes("registry.npmjs.org"); } diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index bc84ac0e..017c8b71 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -884,7 +884,9 @@ describe("NpmPackageRegistry checkAvailability()", () => { await expect( freshRegistry.checkAvailability(makeTask(), makeCtx(true)), - ).rejects.toThrow("npm web login response missing valid loginUrl or doneUrl"); + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); }); it("throws when POST response has invalid URL", async () => { @@ -904,7 +906,9 @@ describe("NpmPackageRegistry checkAvailability()", () => { await expect( freshRegistry.checkAvailability(makeTask(), makeCtx(true)), - ).rejects.toThrow("npm web login response missing valid loginUrl or doneUrl"); + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); }); it("throws when polling returns 200 without token", async () => { @@ -919,7 +923,10 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce( new Response(JSON.stringify({}), { status: 200 }), @@ -942,10 +949,15 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce( - new Response(JSON.stringify({ error: "Internal Server Error" }), { status: 500 }), + new Response(JSON.stringify({ error: "Internal Server Error" }), { + status: 500, + }), ); await expect( @@ -988,7 +1000,10 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce( new Response(JSON.stringify({}), { status: 202 }), @@ -999,9 +1014,7 @@ describe("NpmPackageRegistry checkAvailability()", () => { await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); - const delayCall = setTimeoutSpy.mock.calls.find( - ([, ms]) => ms === 1000, - ); + const delayCall = setTimeoutSpy.mock.calls.find(([, ms]) => ms === 1000); expect(delayCall).toBeDefined(); setTimeoutSpy.mockRestore(); @@ -1023,7 +1036,10 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce(makeDoneResponse("npm_token")); @@ -1046,7 +1062,10 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce(makeDoneResponse("npm_token")); @@ -1107,7 +1126,10 @@ describe("NpmPackageRegistry checkAvailability()", () => { mockedFetch .mockResolvedValueOnce( - makeLoginResponse("https://www.npmjs.com/auth/cli/x", "https://www.npmjs.com/-/v1/login/x/done"), + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), ) .mockResolvedValueOnce(makeDoneResponse("npm_token")); From ebfa71ebf35c13e713ac6f5801c0e0cd756736fc Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 20:49:16 +0900 Subject: [PATCH 6/7] test(core): add comprehensive regression tests for npm direct web login Add 17 new test scenarios covering: - Multiple 202 polling cycles before success - Retry-After header value respected - POST and polling network errors (fetch throws) - Empty JSON body, empty string token, ftp:// protocol URLs - Still not logged in after successful token save - Error message wrapping format verification - Already logged in skips login entirely - Shared promise cleared after failure for retry - Second package sees "Waiting for npm login..." output - LoginUrl with query parameters - doneUrl and token never exposed in task.output (security) - Post-login flow proceeds to N2/N3 checks correctly --- packages/core/tests/unit/registry/npm.test.ts | 485 ++++++++++++++++++ 1 file changed, 485 insertions(+) diff --git a/packages/core/tests/unit/registry/npm.test.ts b/packages/core/tests/unit/registry/npm.test.ts index 017c8b71..b16bf749 100644 --- a/packages/core/tests/unit/registry/npm.test.ts +++ b/packages/core/tests/unit/registry/npm.test.ts @@ -1153,6 +1153,491 @@ describe("NpmPackageRegistry checkAvailability()", () => { expect(loginCalls).toHaveLength(1); expect(ctx.runtime.npmLoginPromise).toBeUndefined(); }); + + it("polls multiple 202 responses before receiving 200 with token", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(make202Response("0.001")) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const pollCalls = mockedFetch.mock.calls.filter( + ([url]) => + typeof url === "string" && url.includes("/-/v1/login/x/done"), + ); + // 3 x 202 + 1 x 200 = 4 total poll requests to doneUrl + expect(pollCalls).toHaveLength(4); + }); + + it("respects Retry-After header value for polling delay", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(make202Response("0.005")) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + const delayCall = setTimeoutSpy.mock.calls.find(([, ms]) => ms === 5); + expect(delayCall).toBeDefined(); + + setTimeoutSpy.mockRestore(); + }); + + it("throws when POST fetch throws a network error", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockRejectedValueOnce(new TypeError("fetch failed")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm login failed"); + }); + + it("throws when polling fetch throws a network error", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockRejectedValueOnce(new TypeError("network timeout")); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm login failed"); + }); + + it("throws when POST returns empty JSON body", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response(JSON.stringify({}), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws when token is an empty string", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ token: "" }), { status: 200 }), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("npm web login completed but no token received"); + }); + + it("rejects ftp:// protocol URLs in login response", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + loginUrl: "ftp://evil.com/login", + doneUrl: "ftp://evil.com/done", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm web login response missing valid loginUrl or doneUrl", + ); + }); + + it("throws 'Still not logged in' when isLoggedIn returns false after successful token save", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Still not logged in after npm login"); + }); + + it("wraps direct web login errors with 'npm login failed:' prefix", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(false); + + mockedFetch.mockResolvedValueOnce(new Response("", { status: 503 })); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow( + "npm login failed: npm web login initiation failed (HTTP 503)", + ); + }); + + it("skips login entirely when already logged in", async () => { + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn").mockResolvedValue(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(mockedFetch).not.toHaveBeenCalledWith( + expect.stringContaining("/-/v1/login"), + expect.anything(), + ); + }); + + it("clears shared promise after direct login failure so retry can start new login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const ctx = makeCtx(true); + + // First attempt: POST fails + mockedFetch.mockResolvedValueOnce(new Response("", { status: 500 })); + + await expect( + freshRegistry.checkAvailability(makeTask(), ctx), + ).rejects.toThrow("npm login failed"); + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + + // Second attempt: succeeds + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/retry", + "https://www.npmjs.com/-/v1/login/retry/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), ctx), + ).resolves.toBeUndefined(); + + expect(ctx.runtime.npmLoginPromise).toBeUndefined(); + }); + + it("second package sees 'Waiting for npm login...' during shared direct login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const firstRegistry = new FreshNpmRegistry("pkg-a", FIXTURE_PATH); + const secondRegistry = new FreshNpmRegistry("pkg-b", FIXTURE_PATH); + + for (const current of [firstRegistry, secondRegistry]) { + vi.spyOn(current, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(current, "isPublished").mockResolvedValue(true); + vi.spyOn(current, "hasPermission").mockResolvedValue(true); + } + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const ctx = makeCtx(true); + const secondTask = makeTask(); + + await Promise.all([ + firstRegistry.checkAvailability(makeTask(), ctx), + secondRegistry.checkAvailability(secondTask, ctx), + ]); + + expect(secondTask.output).toBe("Waiting for npm login..."); + }); + + it("handles loginUrl with query parameters correctly", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const loginUrl = + "https://www.npmjs.com/login?next=/login/cli/abc-123&foo=bar"; + const doneUrl = "https://www.npmjs.com/-/v1/login/abc-123/done"; + + mockedFetch + .mockResolvedValueOnce(makeLoginResponse(loginUrl, doneUrl)) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await freshRegistry.checkAvailability(makeTask(), makeCtx(true)); + + expect(openUrl).toHaveBeenCalledWith(loginUrl); + }); + + it("does not expose doneUrl in task output", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + const doneUrl = "https://www.npmjs.com/-/v1/login/secret-uuid/done"; + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse("https://www.npmjs.com/auth/cli/x", doneUrl), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + expect(task.output).not.toContain(doneUrl); + expect(task.output).not.toContain("secret-uuid/done"); + }); + + it("does not expose token in task output", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(true); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_super-secret-token-xyz")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + const task = makeTask(); + await freshRegistry.checkAvailability(task, makeCtx(true)); + + expect(task.output).not.toContain("npm_super-secret-token-xyz"); + }); + + it("proceeds to N2 permission check after successful direct login", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(true); + vi.spyOn(freshRegistry, "hasPermission").mockResolvedValue(false); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("No permission to publish on npm."); + }); + + it("proceeds to N3 name check after successful direct login for unpublished package", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + vi.doMock("../../../src/utils/open-url.js", () => ({ openUrl })); + vi.resetModules(); + const { NpmPackageRegistry: FreshNpmRegistry } = await import( + "../../../src/registry/npm.js" + ); + const freshRegistry = new FreshNpmRegistry("my-package", FIXTURE_PATH); + vi.spyOn(freshRegistry, "isLoggedIn") + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + vi.spyOn(freshRegistry, "isPublished").mockResolvedValue(false); + vi.spyOn(freshRegistry, "isPackageNameAvailable").mockResolvedValue( + false, + ); + + mockedFetch + .mockResolvedValueOnce( + makeLoginResponse( + "https://www.npmjs.com/auth/cli/x", + "https://www.npmjs.com/-/v1/login/x/done", + ), + ) + .mockResolvedValueOnce(makeDoneResponse("npm_token")); + + mockedExec.mockResolvedValue({ stdout: "", stderr: "" } as any); + + await expect( + freshRegistry.checkAvailability(makeTask(), makeCtx(true)), + ).rejects.toThrow("Package name is not available."); + }); }); describe("N1: login check", () => { From a2b2fcc35d0f07fcb916f4e47abe7aac07a752c9 Mon Sep 17 00:00:00 2001 From: syi0808 Date: Fri, 10 Apr 2026 21:23:54 +0900 Subject: [PATCH 7/7] fix(core): use strict equality for registry check and defer JSON parsing - Replace .includes() with === for isOfficialNpmRegistry to prevent subdomain matching (e.g. my-registry.npmjs.org) - Move .json() call after status check in web login polling to avoid SyntaxError on non-JSON error responses --- packages/core/src/registry/npm.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/registry/npm.ts b/packages/core/src/registry/npm.ts index da5853eb..84c9b730 100644 --- a/packages/core/src/registry/npm.ts +++ b/packages/core/src/registry/npm.ts @@ -491,7 +491,7 @@ export class NpmPackageRegistry extends PackageRegistry { private isOfficialNpmRegistry(): boolean { if (!this.registry) return true; - return normalizeRegistryUrl(this.registry).includes("registry.npmjs.org"); + return normalizeRegistryUrl(this.registry) === "registry.npmjs.org"; } private isProvenanceError(error: unknown): boolean { @@ -585,9 +585,9 @@ export class NpmPackageRegistry extends PackageRegistry { // Step 3: Poll doneUrl while (true) { const pollRes = await fetch(doneUrl); - const pollBody = (await pollRes.json()) as { token?: string }; if (pollRes.status === 200) { + const pollBody = (await pollRes.json()) as { token?: string }; if (!pollBody.token) { throw new NpmError("npm web login completed but no token received"); }