diff --git a/.github/workflows/chrome-web-store-status.yml b/.github/workflows/chrome-web-store-status.yml new file mode 100644 index 0000000..c6a9bc1 --- /dev/null +++ b/.github/workflows/chrome-web-store-status.yml @@ -0,0 +1,47 @@ +name: Chrome Web Store Status + +on: + workflow_dispatch: + inputs: + expected_version: + description: Expected extension version. Defaults to package.json. + required: false + default: "" + require_published: + description: Fail unless the expected version is published. + required: true + default: "false" + type: choice + options: + - "false" + - "true" + schedule: + - cron: "17 */6 * * *" + +permissions: + contents: read + +jobs: + status: + name: Check Chrome Web Store status + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: chrome-web-store-status + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Set up Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e + with: + node-version: 22.13.0 + + - name: Check Chrome Web Store status + run: node scripts/check-chrome-web-store-status.mjs + env: + CWS_EXTENSION_ID: nfnbhekccajjfgkppolomflaeledoccb + CWS_EXPECTED_VERSION: ${{ inputs.expected_version }} + CWS_REQUIRE_PUBLISHED: ${{ inputs.require_published || 'false' }} + CWS_PUBLISHER_ID: ${{ vars.CWS_PUBLISHER_ID }} + CWS_SERVICE_ACCOUNT_JSON: ${{ secrets.CWS_SERVICE_ACCOUNT_JSON }} diff --git a/docs/PUBLICATION_READINESS.md b/docs/PUBLICATION_READINESS.md index 4f1a802..a687f36 100644 --- a/docs/PUBLICATION_READINESS.md +++ b/docs/PUBLICATION_READINESS.md @@ -99,6 +99,8 @@ stable-release claims. - [x] Manifest icons are present in source and verified in the built package. - [x] Manifest homepage URL points to `https://pack.complyeaze.com/gst`. - [x] Protected Chrome Web Store workflow exists for future release updates. +- [x] Protected Chrome Web Store status monitor exists for post-submit + review/publication checks without upload or publish side effects. ### Must Complete Before Future Store Updates Or Broader Store Claims @@ -184,6 +186,10 @@ stable-release claims. - [x] Submit the `v0.3.2` package through the protected Chrome Web Store workflow. Run `28704776806` uploaded the package with Chrome Web Store upload state `SUCCEEDED`, publish state `PENDING_REVIEW`, and no warnings. +- [x] Add a read-only Chrome Web Store status monitor for submitted packages. + Scheduled runs use the dedicated `chrome-web-store-status` environment so + publication/rejection monitoring is not blocked by the protected publishing + approval gate. - [ ] Upload/review the `v0.3.2` GSTR-1 listing copy, screenshots, promotional images, privacy-practices declarations, and reviewer instructions in the Chrome Web Store dashboard, then record review/publication evidence for diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 83dccf4..6118c78 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -144,6 +144,20 @@ script runs. The publish script must receive the matching downloaded release version instead of the workflow checkout's `package.json` version. +Use `Chrome Web Store Status` after a submit run. It calls the Chrome Web Store +API `fetchStatus` endpoint, prints a bounded status summary, and fails on +rejected, cancelled, failed, warned, or taken-down states. By default it +succeeds while the expected version is submitted but still pending review; +dispatch it with `require_published=true` when final publication, not just +submission, is the release gate. + +Configure the status workflow with a dedicated `chrome-web-store-status` +environment that has no required reviewer protection. Give it a read-only +service-account `CWS_SERVICE_ACCOUNT_JSON` secret plus `CWS_PUBLISHER_ID`; do +not copy the publish workflow's OAuth client secret or refresh token into this +environment. Keep the publishing workflow on the protected `chrome-web-store` +environment. + For local dry-runs against a generated release package: ```sh @@ -154,6 +168,14 @@ node scripts/publish-chrome-web-store.mjs \ --dry-run true ``` +For a local status check: + +```sh +node scripts/check-chrome-web-store-status.mjs \ + --publisher-id \ + --expected-version +``` + Do not move listing/support/homepage URLs in the Chrome dashboard without updating `src/extension/manifest-policy.ts`, this runbook, and the public Pack site. diff --git a/package.json b/package.json index a56ed4b..0ac74ac 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "release:provenance": "node scripts/write-release-provenance.mjs", "release:verify-assets": "node scripts/verify-github-release-assets.mjs", "release:chrome-web-store": "node scripts/publish-chrome-web-store.mjs", + "release:chrome-web-store-status": "node scripts/check-chrome-web-store-status.mjs", "store:assets": "node scripts/export-chrome-web-store-assets.mjs", "release:check-pr-title": "node scripts/check-conventional-pr-title.mjs", "verify:clean": "node scripts/assert-clean-worktree.mjs", diff --git a/scripts/check-chrome-web-store-status.d.mts b/scripts/check-chrome-web-store-status.d.mts new file mode 100644 index 0000000..d742005 --- /dev/null +++ b/scripts/check-chrome-web-store-status.d.mts @@ -0,0 +1,35 @@ +export interface CheckChromeWebStoreStatusOptions { + argv?: string[]; + cwd?: string; + env?: Record; + fetchImpl?: (url: string, init?: RequestInit) => Promise; + write?: (line: string) => void; +} + +export interface ChromeWebStoreStatusSummary { + extensionId: string; + publisherId: string | null; + expectedVersion: string | null; + submittedVersion: string | null; + publishedVersion: string | null; + latestObservedVersion: string | null; + states: string[]; + expectedSubmitted: boolean | null; + expectedPublished: boolean | null; + pendingReview: boolean; + published: boolean; + failed: boolean; +} + +export function checkChromeWebStoreStatus( + options?: CheckChromeWebStoreStatusOptions, +): Promise; + +export function summarizeChromeWebStoreStatus( + status: Record, + options?: { + extensionId?: string; + expectedVersion?: string; + publisherId?: string | null; + }, +): ChromeWebStoreStatusSummary; diff --git a/scripts/check-chrome-web-store-status.mjs b/scripts/check-chrome-web-store-status.mjs new file mode 100644 index 0000000..00e532c --- /dev/null +++ b/scripts/check-chrome-web-store-status.mjs @@ -0,0 +1,229 @@ +/* global fetch */ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { fetchChromeWebStoreStatus } from "./publish-chrome-web-store.mjs"; + +const DEFAULT_EXTENSION_ID = "nfnbhekccajjfgkppolomflaeledoccb"; +const FAILURE_STATES = new Set([ + "CANCELLED", + "FAILED", + "FAILURE", + "REJECTED", + "REJECTED_FOR_POLICY", +]); +const PENDING_STATES = new Set([ + "IN_REVIEW", + "PENDING", + "PENDING_REVIEW", + "PENDING_REVIEW_PUBLISH", + "SUBMITTED", +]); +const PUBLISHED_STATES = new Set(["OK", "PUBLISHED", "PUBLIC", "LIVE"]); + +export async function checkChromeWebStoreStatus({ + argv = process.argv.slice(2), + cwd = process.cwd(), + env = process.env, + fetchImpl = fetch, + write = console.log, +} = {}) { + const args = parseArgs(argv); + const extensionId = args.extensionId ?? env.CWS_EXTENSION_ID ?? DEFAULT_EXTENSION_ID; + const expectedVersion = + nonEmptyString(args.expectedVersion) ?? + nonEmptyString(env.CWS_EXPECTED_VERSION) ?? + (await readPackageVersion(cwd)); + const requirePublished = + parseOptionalBoolean(args.requirePublished ?? env.CWS_REQUIRE_PUBLISHED, "requirePublished") ?? + false; + + const status = await fetchChromeWebStoreStatus({ + extensionId, + publisherId: args.publisherId ?? env.CWS_PUBLISHER_ID, + env, + fetchImpl, + }); + const summary = summarizeChromeWebStoreStatus(status, { + extensionId, + expectedVersion, + publisherId: args.publisherId ?? env.CWS_PUBLISHER_ID ?? null, + }); + + assertChromeWebStoreStatus(summary, { requirePublished }); + write(JSON.stringify(summary, null, 2)); + return summary; +} + +export function summarizeChromeWebStoreStatus( + status, + { extensionId = DEFAULT_EXTENSION_ID, expectedVersion, publisherId = null } = {}, +) { + const submittedVersions = uniqueStrings([ + ...distributionVersions(status.submittedItemRevisionStatus), + ...distributionVersions(status.itemRevisionStatus), + ]); + const publishedVersions = uniqueStrings([ + ...distributionVersions(status.publishedItemRevisionStatus), + ...distributionVersions(status.publicItemRevisionStatus), + ]); + const submittedVersion = firstString(submittedVersions); + const publishedVersion = firstString(publishedVersions); + const anyVersion = firstString([...collectValuesByKey(status, "crxVersion")]); + const topLevelStates = [status.itemState, status.state, status.reviewState, status.publishState]; + const submittedRevisionStates = revisionStates(status.submittedItemRevisionStatus); + const publishedRevisionStates = revisionStates(status.publishedItemRevisionStatus); + const states = uniqueStrings([ + status.lastAsyncUploadState, + ...topLevelStates, + ...submittedRevisionStates, + ...publishedRevisionStates, + ]); + const normalizedStates = states.map((state) => state.toUpperCase()); + const normalizedPublishedStates = uniqueStrings([ + ...topLevelStates, + ...publishedRevisionStates, + ]).map((state) => state.toUpperCase()); + const hasFailureState = + normalizedStates.some((state) => FAILURE_STATES.has(state)) || + status.takenDown === true || + status.warned === true; + const hasPendingState = normalizedStates.some((state) => PENDING_STATES.has(state)); + const hasPublishedState = normalizedPublishedStates.some((state) => PUBLISHED_STATES.has(state)); + const expectedSubmitted = expectedVersion ? submittedVersions.includes(expectedVersion) : null; + const expectedPublished = expectedVersion ? publishedVersions.includes(expectedVersion) : null; + const published = + !hasFailureState && hasPublishedState && Boolean(expectedVersion ? expectedPublished : true); + + return { + extensionId, + publisherId, + expectedVersion: expectedVersion ?? null, + submittedVersion: submittedVersion ?? null, + publishedVersion: publishedVersion ?? null, + latestObservedVersion: submittedVersion ?? publishedVersion ?? anyVersion ?? null, + states, + takenDown: status.takenDown === true, + warned: status.warned === true, + expectedSubmitted, + expectedPublished, + pendingReview: hasPendingState && !hasFailureState && !published, + published, + failed: hasFailureState, + }; +} + +function assertChromeWebStoreStatus(summary, { requirePublished }) { + if (summary.failed) { + if (summary.takenDown) { + throw new Error( + `Chrome Web Store item ${summary.extensionId} has been taken down for a policy violation.`, + ); + } + + if (summary.warned) { + throw new Error( + `Chrome Web Store item ${summary.extensionId} has a policy warning that must be resolved.`, + ); + } + + throw new Error( + `Chrome Web Store item ${summary.extensionId} has a failed/rejected state: ${summary.states.join(", ")}`, + ); + } + + if (summary.expectedVersion && !summary.expectedSubmitted && !summary.expectedPublished) { + throw new Error( + `Chrome Web Store status does not show expected version ${summary.expectedVersion}. Latest observed version: ${summary.latestObservedVersion ?? "unknown"}.`, + ); + } + + if (requirePublished && !summary.published) { + throw new Error( + `Chrome Web Store version ${summary.expectedVersion ?? "unknown"} is not published yet.`, + ); + } +} + +async function readPackageVersion(cwd) { + const packageJson = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8")); + if (!packageJson.version) throw new Error("package.json is missing version."); + return packageJson.version; +} + +function distributionVersions(revisionStatus) { + if (!revisionStatus) return []; + return [ + revisionStatus.crxVersion, + ...(revisionStatus.distributionChannels ?? []).map((channel) => channel?.crxVersion), + ].filter(Boolean); +} + +function revisionStates(revisionStatus) { + if (!revisionStatus) return []; + return [revisionStatus.itemState, revisionStatus.state, revisionStatus.reviewState]; +} + +function collectValuesByKey(value, key, seen = new Set()) { + if (!value || typeof value !== "object" || seen.has(value)) return []; + seen.add(value); + const values = []; + + if (Object.prototype.hasOwnProperty.call(value, key)) { + values.push(value[key]); + } + + for (const child of Object.values(value)) { + if (Array.isArray(child)) { + for (const item of child) values.push(...collectValuesByKey(item, key, seen)); + } else if (child && typeof child === "object") { + values.push(...collectValuesByKey(child, key, seen)); + } + } + + return values; +} + +function firstString(values) { + return values.find((value) => typeof value === "string" && value.length > 0) ?? null; +} + +function uniqueStrings(values) { + return Array.from(new Set(values.filter((value) => typeof value === "string" && value))); +} + +function parseArgs(values) { + const parsed = {}; + for (let index = 0; index < values.length; index += 1) { + const key = values[index]; + if (!key?.startsWith("--")) throw new Error(`Unexpected argument: ${key}`); + const value = values[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${key}`); + parsed[toCamelCase(key.slice(2))] = value; + index += 1; + } + return parsed; +} + +function parseOptionalBoolean(value, name) { + if (value === undefined || value === null || value === "") return undefined; + if (value === true || value === "true") return true; + if (value === false || value === "false") return false; + throw new Error(`Expected ${name} to be true or false, got ${value}.`); +} + +function nonEmptyString(value) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function toCamelCase(value) { + return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + checkChromeWebStoreStatus().catch((error) => { + console.error(error.message); + process.exit(1); + }); +} diff --git a/scripts/publish-chrome-web-store.d.mts b/scripts/publish-chrome-web-store.d.mts index eb4008a..25441f9 100644 --- a/scripts/publish-chrome-web-store.d.mts +++ b/scripts/publish-chrome-web-store.d.mts @@ -20,3 +20,10 @@ export function buildPublishRequest(options?: { blockOnWarnings?: boolean; deployPercentage?: string | number | null; }): PublishRequest; + +export function fetchChromeWebStoreStatus(options?: { + extensionId?: string; + publisherId?: string; + env?: Record; + fetchImpl?: (url: string, init?: RequestInit) => Promise; +}): Promise>; diff --git a/scripts/publish-chrome-web-store.mjs b/scripts/publish-chrome-web-store.mjs index d2f2c24..13f8f49 100644 --- a/scripts/publish-chrome-web-store.mjs +++ b/scripts/publish-chrome-web-store.mjs @@ -7,6 +7,8 @@ import { URLSearchParams, pathToFileURL } from "node:url"; const DEFAULT_EXTENSION_ID = "nfnbhekccajjfgkppolomflaeledoccb"; const DEFAULT_UPLOAD_POLL_ATTEMPTS = 30; const DEFAULT_UPLOAD_POLL_INTERVAL_MS = 10_000; +const CWS_WRITE_SCOPE = "https://www.googleapis.com/auth/chromewebstore"; +const CWS_READONLY_SCOPE = "https://www.googleapis.com/auth/chromewebstore.readonly"; const UPLOAD_IN_PROGRESS_STATES = new Set(["IN_PROGRESS", "UPLOAD_IN_PROGRESS"]); const UPLOAD_SUCCESS_STATES = new Set([ "SUCCEEDED", @@ -16,6 +18,25 @@ const UPLOAD_SUCCESS_STATES = new Set([ ]); const UPLOAD_FAILURE_STATES = new Set(["FAILED", "NOT_FOUND", "UPLOAD_FAILED"]); +export async function fetchChromeWebStoreStatus({ + extensionId = DEFAULT_EXTENSION_ID, + publisherId, + env = process.env, + fetchImpl = fetch, +} = {}) { + const selectedPublisherId = publisherId ?? env.CWS_PUBLISHER_ID; + if (!selectedPublisherId) throw new Error("Missing CWS_PUBLISHER_ID or --publisher-id."); + + const accessToken = await getAccessToken(env, fetchImpl, { scope: CWS_READONLY_SCOPE }); + const name = `publishers/${selectedPublisherId}/items/${extensionId}`; + return getJson( + `https://chromewebstore.googleapis.com/v2/${name}:fetchStatus`, + accessToken, + fetchImpl, + "Chrome Web Store fetchStatus failed", + ); +} + export async function publishChromeWebStorePackage({ argv = process.argv.slice(2), cwd = process.cwd(), @@ -285,10 +306,10 @@ export async function waitForUploadCompletion({ ); } -async function getAccessToken(env, fetchImpl) { +async function getAccessToken(env, fetchImpl, { scope = CWS_WRITE_SCOPE } = {}) { if (env.CWS_ACCESS_TOKEN) return env.CWS_ACCESS_TOKEN; if (env.CWS_SERVICE_ACCOUNT_JSON) { - return serviceAccountAccessToken(JSON.parse(env.CWS_SERVICE_ACCOUNT_JSON), fetchImpl); + return serviceAccountAccessToken(JSON.parse(env.CWS_SERVICE_ACCOUNT_JSON), fetchImpl, scope); } if (env.CWS_CLIENT_ID && env.CWS_CLIENT_SECRET && env.CWS_REFRESH_TOKEN) { return refreshTokenAccessToken({ @@ -303,14 +324,14 @@ async function getAccessToken(env, fetchImpl) { ); } -async function serviceAccountAccessToken(serviceAccount, fetchImpl) { +async function serviceAccountAccessToken(serviceAccount, fetchImpl, scope) { const tokenUri = serviceAccount.token_uri ?? "https://oauth2.googleapis.com/token"; const iat = Math.floor(Date.now() / 1000); const assertion = [ base64UrlJson({ alg: "RS256", typ: "JWT" }), base64UrlJson({ iss: serviceAccount.client_email, - scope: "https://www.googleapis.com/auth/chromewebstore", + scope, aud: tokenUri, exp: iat + 3600, iat, diff --git a/tests/extension/ci-workflow.test.ts b/tests/extension/ci-workflow.test.ts index 51dc413..7568603 100644 --- a/tests/extension/ci-workflow.test.ts +++ b/tests/extension/ci-workflow.test.ts @@ -131,4 +131,23 @@ describe("Pack CI workflow", () => { expect(releaseRunbook).toContain("CWS_SUBMIT_ENABLED"); expect(releaseRunbook).toContain("CWS_SUBMIT_ENABLED=true"); }); + + it("monitors Chrome Web Store review status without publishing side effects", async () => { + const statusWorkflow = await readFile( + path.join(rootDir, ".github", "workflows", "chrome-web-store-status.yml"), + "utf8", + ); + + expect(statusWorkflow).toContain("schedule:"); + expect(statusWorkflow).toContain("workflow_dispatch:"); + expect(statusWorkflow).toContain("environment: chrome-web-store-status"); + expect(statusWorkflow).toContain("node scripts/check-chrome-web-store-status.mjs"); + expect(statusWorkflow).toContain("CWS_REQUIRE_PUBLISHED"); + expect(statusWorkflow).toContain("CWS_SERVICE_ACCOUNT_JSON"); + expect(statusWorkflow).not.toContain("CWS_REFRESH_TOKEN"); + expect(statusWorkflow).not.toContain("CWS_CLIENT_SECRET"); + expect(statusWorkflow).not.toContain("scripts/publish-chrome-web-store.mjs"); + expect(statusWorkflow).not.toContain(":publish"); + expect(statusWorkflow).not.toContain(":upload"); + }); }); diff --git a/tests/scripts/check-chrome-web-store-status.test.ts b/tests/scripts/check-chrome-web-store-status.test.ts new file mode 100644 index 0000000..5b24edd --- /dev/null +++ b/tests/scripts/check-chrome-web-store-status.test.ts @@ -0,0 +1,360 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { + checkChromeWebStoreStatus, + summarizeChromeWebStoreStatus, +} from "../../scripts/check-chrome-web-store-status.mjs"; + +describe("Chrome Web Store status monitor", () => { + it("summarizes the submitted package while review is pending", () => { + const summary = summarizeChromeWebStoreStatus( + { + lastAsyncUploadState: "SUCCEEDED", + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + publishedItemRevisionStatus: { + distributionChannels: [{ crxVersion: "0.2.1" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: false, + expectedSubmitted: true, + failed: false, + pendingReview: true, + published: false, + publishedVersion: "0.2.1", + submittedVersion: "0.3.2", + }); + }); + + it("marks the expected version published when the public revision matches", () => { + const summary = summarizeChromeWebStoreStatus( + { + itemState: "PUBLISHED", + publishedItemRevisionStatus: { + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: true, + pendingReview: false, + published: true, + publishedVersion: "0.3.2", + takenDown: false, + warned: false, + }); + }); + + it("matches the expected version across every distribution channel", () => { + const summary = summarizeChromeWebStoreStatus( + { + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.1" }, { crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedSubmitted: true, + failed: false, + pendingReview: true, + published: false, + submittedVersion: "0.3.1", + }); + }); + + it("does not treat tester-only availability as public publication", () => { + const summary = summarizeChromeWebStoreStatus( + { + publishedItemRevisionStatus: { + state: "PUBLISHED_TO_TESTERS", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }, + { expectedVersion: "0.3.2", extensionId: "ext-1", publisherId: "pub-1" }, + ); + + expect(summary).toMatchObject({ + expectedPublished: true, + failed: false, + pendingReview: false, + published: false, + publishedVersion: "0.3.2", + }); + }); + + it("fails on rejected review state", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "REJECTED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + cwd, + env: { + CWS_CLIENT_ID: "client-1", + CWS_CLIENT_SECRET: "secret-1", + CWS_PUBLISHER_ID: "pub-1", + CWS_REFRESH_TOKEN: "refresh-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("failed/rejected state: REJECTED"); + }); + + it("fails on cancelled submissions for the expected version", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "CANCELLED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("failed/rejected state: CANCELLED"); + }); + + it("fails when fetchStatus reports the item has been taken down", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + takenDown: true, + publishedItemRevisionStatus: { + state: "PUBLISHED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("has been taken down for a policy violation"); + }); + + it("fails when fetchStatus reports a policy warning", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + warned: true, + publishedItemRevisionStatus: { + state: "PUBLISHED", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("has a policy warning that must be resolved"); + }); + + it("uses fetchStatus and package.json version by default", async () => { + const cwd = await writePackageFixture("0.3.2"); + const calls: Array<{ method: string | undefined; url: string }> = []; + const output: string[] = []; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + calls.push({ method: init?.method, url }); + + if (url === "https://oauth2.googleapis.com/token") { + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + lastAsyncUploadState: "SUCCEEDED", + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + const summary = await checkChromeWebStoreStatus({ + cwd, + env: { + CWS_CLIENT_ID: "client-1", + CWS_CLIENT_SECRET: "secret-1", + CWS_EXPECTED_VERSION: "", + CWS_PUBLISHER_ID: "pub-1", + CWS_REFRESH_TOKEN: "refresh-1", + }, + fetchImpl, + write: (line: string) => output.push(line), + }); + + expect(calls.map((call) => call.method)).toEqual(["POST", "GET"]); + expect(calls[1]?.url).toBe( + "https://chromewebstore.googleapis.com/v2/publishers/pub-1/items/nfnbhekccajjfgkppolomflaeledoccb:fetchStatus", + ); + expect(summary).toMatchObject({ + expectedVersion: "0.3.2", + expectedSubmitted: true, + pendingReview: true, + }); + expect(JSON.parse(output[0] ?? "{}")).toMatchObject({ + expectedVersion: "0.3.2", + submittedVersion: "0.3.2", + }); + }); + + it("requests read-only service-account tokens for fetchStatus", async () => { + const cwd = await writePackageFixture("0.3.2"); + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwtPayloads: Array<{ scope?: string }> = []; + const fetchImpl = vi.fn(async (url: string, init?: RequestInit) => { + if (url === "https://oauth2.googleapis.com/token") { + const body = init?.body as URLSearchParams; + const assertion = body.get("assertion") ?? ""; + jwtPayloads.push(decodeJwtPayload(assertion)); + return jsonResponse({ access_token: "token-1" }); + } + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await checkChromeWebStoreStatus({ + cwd, + env: { + CWS_EXPECTED_VERSION: "0.3.2", + CWS_PUBLISHER_ID: "pub-1", + CWS_SERVICE_ACCOUNT_JSON: JSON.stringify({ + client_email: "pack-status@example.iam.gserviceaccount.com", + private_key: privateKey.export({ format: "pem", type: "pkcs8" }), + token_uri: "https://oauth2.googleapis.com/token", + }), + }, + fetchImpl, + write: vi.fn(), + }); + + expect(jwtPayloads[0]?.scope).toBe("https://www.googleapis.com/auth/chromewebstore.readonly"); + }); + + it("can require the expected version to be published", async () => { + const cwd = await writePackageFixture("0.3.2"); + const fetchImpl = vi.fn(async (url: string) => { + if (url.endsWith(":fetchStatus")) { + return jsonResponse({ + submittedItemRevisionStatus: { + state: "PENDING_REVIEW", + distributionChannels: [{ crxVersion: "0.3.2" }], + }, + }); + } + + throw new Error(`Unexpected URL: ${url}`); + }); + + await expect( + checkChromeWebStoreStatus({ + argv: ["--require-published", "true"], + cwd, + env: { + CWS_ACCESS_TOKEN: "token-1", + CWS_PUBLISHER_ID: "pub-1", + }, + fetchImpl, + write: vi.fn(), + }), + ).rejects.toThrow("Chrome Web Store version 0.3.2 is not published yet."); + }); +}); + +async function writePackageFixture(version: string) { + const cwd = await mkdtemp(path.join(tmpdir(), "pack-cws-status-test-")); + await writeFile(path.join(cwd, "package.json"), JSON.stringify({ version })); + return cwd; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + text: async () => JSON.stringify(body), + } as Response; +} + +function decodeJwtPayload(assertion: string) { + const payload = assertion.split(".")[1] ?? ""; + return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { scope?: string }; +}