diff --git a/packages/deploy-cloudflare/src/existing.ts b/packages/deploy-cloudflare/src/existing.ts index cec61ebd5..a416b092e 100644 --- a/packages/deploy-cloudflare/src/existing.ts +++ b/packages/deploy-cloudflare/src/existing.ts @@ -3,7 +3,11 @@ import { isRecord } from '@void/deploy-core'; import { parseRootWranglerConfig } from './config.ts'; import { isCleanCommandExit, isWorkerNotFound, type CloudflareCommandRunner } from './execute.ts'; import type { CloudflareOperation } from './operations.ts'; -import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from './secret-draft.ts'; +import { + isVoidSecretDraftHeader, + isVoidSecretOnlyDraft, + isWranglerSecretOnlyDraft, +} from './secret-draft.ts'; export type ExistingCloudflareWorker = { versionId: string; @@ -115,9 +119,10 @@ async function readCloudflareWorker( if ( proveSecretDraft && !isSecretOnlyDraft && - isRecord(version.annotations) && - Object.keys(version.annotations).length === 1 && - version.annotations['workers/triggered_by'] === 'secret' + ((isRecord(version.annotations) && + Object.keys(version.annotations).length === 1 && + version.annotations['workers/triggered_by'] === 'secret') || + isVoidSecretDraftHeader(version)) ) { const source = await read({ type: 'versions.view', versionId, includeSource: true, ...target }); if (source?.id === versionId) { diff --git a/packages/deploy-cloudflare/src/index.ts b/packages/deploy-cloudflare/src/index.ts index 92de93a09..3d5507cd8 100644 --- a/packages/deploy-cloudflare/src/index.ts +++ b/packages/deploy-cloudflare/src/index.ts @@ -55,7 +55,7 @@ export { } from './version.ts'; export { createCloudflareMetadataRunner } from './metadata.ts'; export { cloudflareCommandDiagnostic } from './diagnostics.ts'; -export { VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts'; +export { VOID_SECRET_DRAFT_MESSAGE, VOID_SECRET_DRAFT_SOURCE } from './secret-draft.ts'; export type { CloudflareOperation, diff --git a/packages/deploy-cloudflare/src/secret-draft.ts b/packages/deploy-cloudflare/src/secret-draft.ts index d8984603e..a48499dd0 100644 --- a/packages/deploy-cloudflare/src/secret-draft.ts +++ b/packages/deploy-cloudflare/src/secret-draft.ts @@ -67,7 +67,33 @@ export function isWranglerSecretOnlyDraft(entry: Record): boole export const VOID_SECRET_DRAFT_SOURCE = 'export default { fetch() { return new Response(""); } };\n'; -/** Prove a cf-created placeholder from the exact immutable version's source. */ +/** Message on the placeholder that Void's secret transport deploys to a Worker without code. */ +export const VOID_SECRET_DRAFT_MESSAGE = 'void:secret-draft'; + +/** + * Annotations of a Void secret placeholder, checked before its source is inspected: + * - `api`: the secret transport uploaded and deployed the placeholder and its first secrets in + * one Workers API request. + * - `cf_cli`: `cf workers secrets` added secrets to a deployed placeholder. + */ +export function isVoidSecretDraftHeader(entry: Record): boolean { + if (!isRecord(entry.annotations)) { + return false; + } + const annotations = entry.annotations; + const keys = Object.keys(annotations); + if (entry.source === 'cf_cli') { + return keys.length === 1 && annotations['workers/triggered_by'] === 'secret'; + } + return ( + entry.source === 'api' && + keys.length === 2 && + annotations['workers/message'] === VOID_SECRET_DRAFT_MESSAGE && + annotations['workers/triggered_by'] === 'create_version_api' + ); +} + +/** Prove a Void secret placeholder from the exact immutable version's source. */ export function isVoidSecretOnlyDraft(entry: Record): boolean { const metadataFields = new Set([ 'id', @@ -90,10 +116,7 @@ export function isVoidSecretOnlyDraft(entry: Record): boolean { ]); if ( Object.keys(entry).some((key) => !metadataFields.has(key)) || - entry.source !== 'cf_cli' || - !isRecord(entry.annotations) || - Object.keys(entry.annotations).length !== 1 || - entry.annotations['workers/triggered_by'] !== 'secret' || + !isVoidSecretDraftHeader(entry) || entry.main_module !== 'index.js' || !Array.isArray(entry.urls) || entry.urls.length !== 0 || diff --git a/packages/deploy-cloudflare/src/version.ts b/packages/deploy-cloudflare/src/version.ts index d5d6028d4..5235a338f 100644 --- a/packages/deploy-cloudflare/src/version.ts +++ b/packages/deploy-cloudflare/src/version.ts @@ -16,8 +16,10 @@ import { } from './execute.ts'; import type { CloudflareUploadResult } from './operations.ts'; import { + isVoidSecretDraftHeader, isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft, + VOID_SECRET_DRAFT_MESSAGE, VOID_SECRET_DRAFT_SOURCE, } from './secret-draft.ts'; @@ -1894,8 +1896,9 @@ async function readCloudflareVersionInspection( let draft = entry; let draftUnknown = false; if ( - annotations?.['workers/triggered_by'] === 'secret' && - Object.keys(annotations).length === 1 && + ((annotations?.['workers/triggered_by'] === 'secret' && + Object.keys(annotations).length === 1) || + isVoidSecretDraftHeader(entry)) && !isWranglerSecretOnlyDraft(entry) ) { draftUnknown = true; @@ -1935,10 +1938,6 @@ async function readCloudflareVersionInspection( return null; } -// Message Void's secret transport gives the zero-binding placeholder it deploys when a Worker -// does not exist yet (packages/void cloudflare-operation-process.ts). -const VOID_SECRET_DRAFT_MESSAGE = 'void:secret-draft'; - /** * Recovery-only proof that a version is Void's secret placeholder: its marker plus the exact * placeholder source, with no binding other than secrets. It may have zero bindings, so it is diff --git a/packages/deploy-cloudflare/test/secret-draft-fixture.ts b/packages/deploy-cloudflare/test/secret-draft-fixture.ts index 62f52b0a0..e361765fa 100644 --- a/packages/deploy-cloudflare/test/secret-draft-fixture.ts +++ b/packages/deploy-cloudflare/test/secret-draft-fixture.ts @@ -31,3 +31,17 @@ export function voidSecretDraftVersion(): Record & { author_id: 'synthetic-author', }; } + +/** Sanitized shape of the placeholder that Void's transport uploads and deploys in one request. */ +export function apiSecretDraftVersion(): ReturnType { + return { + ...voidSecretDraftVersion(), + number: 1, + startup_time_ms: 0, + annotations: { + 'workers/message': 'void:secret-draft', + 'workers/triggered_by': 'create_version_api', + }, + source: 'api', + }; +} diff --git a/packages/deploy-cloudflare/test/unit/existing.test.ts b/packages/deploy-cloudflare/test/unit/existing.test.ts index 439747dff..0e0d21a48 100644 --- a/packages/deploy-cloudflare/test/unit/existing.test.ts +++ b/packages/deploy-cloudflare/test/unit/existing.test.ts @@ -15,7 +15,7 @@ import { } from '../../src/existing.ts'; import type { CloudflareOperation } from '../../src/operations.ts'; import { operationFixture } from '../operation-fixture.ts'; -import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; +import { apiSecretDraftVersion, voidSecretDraftVersion } from '../secret-draft-fixture.ts'; const worker: ExistingCloudflareWorker = { versionId: 'original', @@ -498,10 +498,11 @@ describe('existing Worker preservation', () => { ); }); - it.each(['placeholder', 'real-code', 'owner-drift'] as const)( + it.each(['placeholder', 'api-placeholder', 'real-code', 'owner-drift'] as const)( 'verifies immutable SDK secret-draft proof before its first DO migration: %s', async (scenario) => { - const source = voidSecretDraftVersion(); + const source = + scenario === 'api-placeholder' ? apiSecretDraftVersion() : voidSecretDraftVersion(); if (scenario === 'real-code') { source.modules[0]!.content_base64 = Buffer.from( 'export default { fetch() { return new Response("app"); } };', @@ -531,6 +532,7 @@ describe('existing Worker preservation', () => { stderr: '', data: { id: operation.versionId, + source: source.source, annotations: source.annotations, resources: { bindings: source.bindings, @@ -557,7 +559,7 @@ describe('existing Worker preservation', () => { apiBaseUrl: 'https://api.cloudflare.test/client/v4', readMigrationTag: async () => null, }); - if (scenario === 'placeholder') { + if (scenario === 'placeholder' || scenario === 'api-placeholder') { await expect(inspection).resolves.toBeUndefined(); } else { await expect(inspection).rejects.toThrow( diff --git a/packages/deploy-cloudflare/test/unit/secret-draft.test.ts b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts index 8e94b0c99..1e2a2833a 100644 --- a/packages/deploy-cloudflare/test/unit/secret-draft.test.ts +++ b/packages/deploy-cloudflare/test/unit/secret-draft.test.ts @@ -1,8 +1,28 @@ import { describe, expect, it } from 'vite-plus/test'; import { isVoidSecretOnlyDraft, isWranglerSecretOnlyDraft } from '../../src/secret-draft.ts'; -import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; +import { apiSecretDraftVersion, voidSecretDraftVersion } from '../secret-draft-fixture.ts'; describe('immutable SDK secret draft proof', () => { + it('recognizes the placeholder that the secret transport deploys in one request', () => { + const draft = apiSecretDraftVersion(); + expect(isVoidSecretOnlyDraft(draft)).toBe(true); + expect(isVoidSecretOnlyDraft({ ...draft, source: 'cf_cli' })).toBe(false); + expect(isVoidSecretOnlyDraft({ ...draft, source: 'dash' })).toBe(false); + expect( + isVoidSecretOnlyDraft({ + ...draft, + annotations: { ...draft.annotations, 'workers/tag': 'release' }, + }), + ).toBe(false); + expect( + isVoidSecretOnlyDraft({ + ...draft, + annotations: { 'workers/triggered_by': 'create_version_api' }, + }), + ).toBe(false); + expect(isVoidSecretOnlyDraft({ ...draft, bindings: [], env: {} })).toBe(false); + }); + it('recognizes the actual cf placeholder without treating its compatibility date as proof', () => { const draft = voidSecretDraftVersion(); expect(isWranglerSecretOnlyDraft(draft)).toBe(false); diff --git a/packages/deploy-cloudflare/test/unit/version.test.ts b/packages/deploy-cloudflare/test/unit/version.test.ts index 8cb476a7b..85104b6b7 100644 --- a/packages/deploy-cloudflare/test/unit/version.test.ts +++ b/packages/deploy-cloudflare/test/unit/version.test.ts @@ -17,7 +17,7 @@ import { type CloudflareTriggerConfig, } from '../../src/index.ts'; import { operationFixture } from '../operation-fixture.ts'; -import { voidSecretDraftVersion } from '../secret-draft-fixture.ts'; +import { apiSecretDraftVersion, voidSecretDraftVersion } from '../secret-draft-fixture.ts'; const CURRENT = '11111111-1111-4111-8111-111111111111'; const NEXT = '22222222-2222-4222-8222-222222222222'; @@ -58,7 +58,10 @@ function readConfigArg(args: Array): Record { return JSON.parse(readFileSync(args[args.indexOf('-c') + 1]!, 'utf8')) as Record; } -function sdkDraftActivation(source: Record = voidSecretDraftVersion()) { +function sdkDraftActivation( + source: Record = voidSecretDraftVersion(), + listed: Record = { annotations: { 'workers/triggered_by': 'secret' } }, +) { const events: Array = []; const operations: Array = []; const message = encodeCloudflareVersionMessage([], triggersWith()); @@ -81,9 +84,7 @@ function sdkDraftActivation(source: Record = voidSecretDraftVer { id: CURRENT, number: 2, - annotations: ordinary - ? { 'workers/message': message } - : { 'workers/triggered_by': 'secret' }, + ...(ordinary ? { annotations: { 'workers/message': message } } : listed), }, ...(uploaded ? [{ id: NEXT, number: 3, annotations: { 'workers/message': message } }] @@ -625,6 +626,47 @@ describe('Cloudflare version activation', () => { ).toBe(false); }); + it('publishes over the placeholder that the secret transport deployed in one request', async () => { + const draft = apiSecretDraftVersion(); + const fixture = sdkDraftActivation(draft, { + source: draft.source, + annotations: draft.annotations, + }); + const result = await activateCloudflareVersion(fixture.options); + expect(result).toMatchObject({ ok: true, versionId: NEXT, previousVersionId: CURRENT }); + expect(fixture.events.slice(0, 3)).toEqual(['source', 'pin', 'publish']); + expect(fixture.pinActiveSecrets).toHaveBeenCalledExactlyOnceWith(CURRENT, [ + { name: 'TOKEN', type: 'secret_text' }, + ]); + expect(fixture.operations.filter((op) => op.type === 'worker.deploy')).toHaveLength(1); + }); + + it.each([ + [ + 'application code', + { modules: [{ ...apiSecretDraftVersion().modules[0], content_base64: 'YXBw' }] }, + ], + ['another source', { source: 'dash' }], + [ + 'another message', + { + annotations: { 'workers/message': 'release', 'workers/triggered_by': 'create_version_api' }, + }, + ], + ])( + 'does not treat an API-deployed version with %s as the placeholder', + async (_label, change) => { + const draft: Record = { ...apiSecretDraftVersion(), ...change }; + const fixture = sdkDraftActivation(draft, { + source: draft.source, + annotations: draft.annotations, + }); + await activateCloudflareVersion(fixture.options); + expect(fixture.operations.some((op) => op.type === 'worker.deploy')).toBe(false); + expect(fixture.events).not.toContain('publish'); + }, + ); + it('publishes an exact private SDK draft once, proving secrets and callbacks before readiness', async () => { const fixture = sdkDraftActivation({ ...voidSecretDraftVersion(), diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index e3c9e0811..e8be9ca40 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -505,7 +505,9 @@ function apiHeaders(): Promise> { async function readApiHeaders(): Promise> { const key = process.env.CLOUDFLARE_API_KEY ?? process.env.CF_API_KEY; const email = process.env.CLOUDFLARE_EMAIL ?? process.env.CF_EMAIL; - if (key && email) { + // Prefer an API token, as cf does, so direct requests use the same credential as cf commands. + const token = process.env.CLOUDFLARE_API_TOKEN || process.env.CF_API_TOKEN; + if (key && email && !token) { return { 'User-Agent': getVoidUserAgent(), 'X-Auth-Key': key, 'X-Auth-Email': email }; } const credential = await sessionCredential(); @@ -950,24 +952,114 @@ function email(): Result { } } +async function postWorkersApi( + path: string, + body: unknown, +): Promise<{ status: number; body: Json | null }> { + const response = await fetch( + `${apiBaseUrl()}/accounts/${encodeURIComponent(accountId())}/workers/workers${path}`, + { + method: 'POST', + headers: { ...(await apiHeaders()), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + redirect: 'error', + signal: AbortSignal.timeout(30_000), + }, + ); + const parsed: unknown = await response.json().catch(() => null); + return { + status: response.status, + body: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Json) : null, + }; +} + +/** An error that keeps the first few reasons Cloudflare gave, so the user can act on them. */ +function workersApiFailure(action: string, response: { status: number; body: Json | null }): Error { + const errors = Array.isArray(response.body?.errors) ? response.body.errors : []; + const reasons = errors.slice(0, 3).flatMap((error: unknown) => { + if (!error || typeof error !== 'object') { + return []; + } + const { code, message } = error as Json; + if (typeof message !== 'string' || !message) { + return []; + } + return [`${typeof code === 'number' ? `code ${code}: ` : ''}${message.slice(0, 300)}`]; + }); + return new Error( + [`Cloudflare did not ${action} (HTTP ${response.status}).`, ...reasons].join('\n'), + ); +} + +function workerWithoutDeploymentMessage(worker: string): string { + return [ + `Cloudflare Worker "${worker}" exists but has no deployment, so Void cannot set its secrets.`, + 'If a deploy or secret command is running for this Worker, wait for it to finish and run this command again.', + `Otherwise, run \`void deploy\` first. If the deploy stops because a required secret is missing, delete the Worker "${worker}" in the Cloudflare dashboard (Workers & Pages) and run this command again.`, + ].join('\n'); +} + +/** + * Set secrets on a Worker that has no deployed code yet. + * + * Cloudflare keeps secrets on deployed Worker versions, so the secrets go live inside an empty + * placeholder that the first `void deploy` replaces. Deploy that placeholder only to a Worker this + * command created: when a first `void deploy` or another secret command creates the Worker after + * the 10007, a placeholder would replace that app or its secrets. The Worker create fails (409) + * when the name is taken, and the placeholder and its secrets go live in one request. + * + * A Worker that exists without a deployment (another command is setting it up, or an earlier one + * failed after the create) cannot tell those cases apart, so this fails with the recovery steps. + */ +async function setSecretsOnWorkerWithoutCode( + worker: string, + values: Record, + retry: () => Result, +): Promise { + const created = await postWorkersApi('', { + name: worker, + subdomain: { enabled: false, previews_enabled: false }, + }); + if (created.status === 409) { + const retried = retry(); + // 10007 or 10013: the Worker still has no deployment. + if (retried.status === 0 || (retried.errorCode !== 10007 && retried.errorCode !== 10013)) { + return retried; + } + return result(1, '', workerWithoutDeploymentMessage(worker)); + } + if (created.status !== 200 || created.body?.success !== true) { + throw workersApiFailure('create the Worker', created); + } + const { VOID_SECRET_DRAFT_MESSAGE, VOID_SECRET_DRAFT_SOURCE } = + await import('@void/deploy-cloudflare'); + const uploaded = await postWorkersApi(`/${encodeURIComponent(worker)}/versions?deploy=true`, { + main_module: 'index.js', + modules: [ + { + name: 'index.js', + content_type: 'application/javascript+module', + content_base64: Buffer.from(VOID_SECRET_DRAFT_SOURCE).toString('base64'), + }, + ], + compatibility_date: config().compatibility_date ?? '2025-01-01', + bindings: Object.entries(values).map(([name, text]) => ({ type: 'secret_text', name, text })), + annotations: { 'workers/message': VOID_SECRET_DRAFT_MESSAGE }, + }); + if (uploaded.status !== 200 || uploaded.body?.success !== true) { + throw workersApiFailure('deploy the secret placeholder', uploaded); + } + return result(0); +} + async function secrets(): Promise { const worker = workerName(); - const apply = async (action: () => Result): Promise => { + const apply = async (values: Record, action: () => Result): Promise => { const first = action(); if (first.status === 0 || first.errorCode !== 10007) { return first; } - const raw = { - name: worker, - account_id: process.env.CLOUDFLARE_ACCOUNT_ID ?? config().account_id, - compatibility_date: config().compatibility_date ?? '2025-01-01', - workers_dev: false, - preview_urls: false, - }; - const draft = await withBuildOutput(raw, null, (directory) => - runCf(['deploy', '--prebuilt', '--message', 'void:secret-draft'], directory), - ); - return draft.status === 0 ? action() : draft; + return setSecretsOnWorkerWithoutCode(worker, values, action); }; switch (operation.type) { case 'secret.list': @@ -976,18 +1068,27 @@ async function secrets(): Promise { const value = readFileSync(0, 'utf8').replace(/\n$/, ''); const name = operation.name; return withSecretFile(JSON.stringify({ name, text: value, type: 'secret_text' }), (path) => - apply(() => + apply({ [name]: value }, () => runCf(['workers', 'secrets', 'update', name, '--worker', worker, '--body', '@' + path]), ), ); } case 'secret.bulk': { const values = object(parsedJson(readFileSync(0, 'utf8'))); + const texts: Record = {}; + for (const [name, text] of Object.entries(values)) { + if (typeof text !== 'string') { + throw new Error('Secret values must be strings.'); + } + texts[name] = text; + } const patch = Object.fromEntries( - Object.entries(values).map(([name, text]) => [name, { type: 'secret_text', text }]), + Object.entries(texts).map(([name, text]) => [name, { type: 'secret_text', text }]), ); return withSecretFile(JSON.stringify({ secrets: patch }), (path) => - apply(() => runCf(['workers', 'secrets', 'bulk', '--worker', worker, '--file', path])), + apply(texts, () => + runCf(['workers', 'secrets', 'bulk', '--worker', worker, '--file', path]), + ), ); } case 'secret.delete': diff --git a/packages/void/test/helpers/cloudflare-operation-process.ts b/packages/void/test/helpers/cloudflare-operation-process.ts new file mode 100644 index 000000000..2d35b0f23 --- /dev/null +++ b/packages/void/test/helpers/cloudflare-operation-process.ts @@ -0,0 +1,89 @@ +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { CloudflareOperation } from '@void/deploy-cloudflare'; +import { afterEach } from 'vite-plus/test'; +import { encodeCloudflareOperation } from '../../src/cli/cloudflare-process.ts'; +import { resolveCloudflareOperationArgv } from '../../src/cli/preset.ts'; + +/** Cleanups run after each test, last registered first. Tests may push their own. */ +export const cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } +}); + +export function temporaryDirectory(prefix: string): string { + const directory = mkdtempSync(join(tmpdir(), prefix)); + cleanups.push(() => rmSync(directory, { recursive: true, force: true })); + return directory; +} + +/** Serve on a random local port until the test ends and return the origin. */ +export async function listen(server: Server): Promise { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + cleanups.push( + () => + new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }), + ); + return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +} + +// Children get only what Node needs, so host credentials cannot leak into a test. +export const systemEnv: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + WINDIR: process.env.WINDIR, + TMP: process.env.TMP, + TEMP: process.env.TEMP, + NODE_COMPILE_CACHE: process.env.NODE_COMPILE_CACHE, + NODE_DISABLE_COMPILE_CACHE: process.env.NODE_DISABLE_COMPILE_CACHE, +}; + +export function tokenEnv( + api?: string, + token: 'CLOUDFLARE_API_TOKEN' | 'CF_API_TOKEN' = 'CLOUDFLARE_API_TOKEN', +): NodeJS.ProcessEnv { + return { + ...systemEnv, + CI: 'true', + [token]: 'synthetic-token', + CLOUDFLARE_ACCOUNT_ID: 'test-account', + ...(api ? { CLOUDFLARE_API_BASE_URL: `${api}/client/v4` } : {}), + }; +} + +/** + * Run the operation process the CLI spawns. `preload` is imported first to stub cf, and `input`, + * when given, is written to its stdin. + */ +export function runOperation( + cwd: string, + request: CloudflareOperation | Array, + env: NodeJS.ProcessEnv, + preload?: string, + input?: string, +): Promise<{ status: number; stdout: string; stderr: string }> { + const [command, ...prefix] = resolveCloudflareOperationArgv(); + const args = Array.isArray(request) ? request : encodeCloudflareOperation(request); + return new Promise((resolve) => { + const child = execFile( + command!, + [...(preload ? ['--import', pathToFileURL(preload).href] : []), ...prefix, ...args], + { cwd, encoding: 'utf8', timeout: 20_000, maxBuffer: 128 * 1024 * 1024, env }, + (error, stdout, stderr) => + resolve({ status: error ? Number(error.code) || 1 : 0, stdout, stderr }), + ); + if (input !== undefined) { + child.stdin!.end(input); + } + }); +} diff --git a/packages/void/test/unit/cloudflare-operation-process-secrets.test.ts b/packages/void/test/unit/cloudflare-operation-process-secrets.test.ts index 933833d26..d098e90d3 100644 --- a/packages/void/test/unit/cloudflare-operation-process-secrets.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process-secrets.test.ts @@ -1,114 +1,149 @@ -import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer, type IncomingHttpHeaders } from 'node:http'; import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import type { CloudflareOperation } from '@void/deploy-cloudflare'; +import { + VOID_SECRET_DRAFT_MESSAGE, + VOID_SECRET_DRAFT_SOURCE, + type CloudflareOperation, +} from '@void/deploy-cloudflare'; import { expect, it } from 'vite-plus/test'; +import { decodeCloudflareOperationResult } from '../../src/cli/cloudflare-process.ts'; import { - decodeCloudflareOperationResult, - encodeCloudflareOperation, -} from '../../src/cli/cloudflare-process.ts'; - -function runSecretBulk( - input: string, - options?: { preloadBody?: string; recordCallsPath?: string }, -) { - const directory = mkdtempSync(join(tmpdir(), 'void-cf-secret-bulk-test-')); - try { - const config = join(directory, 'wrangler.json'); - const capture = join(directory, 'cf-args.json'); - const bulkBody = join(directory, 'bulk-body.json'); - const callLog = join(directory, 'cf-call-log.json'); - const preload = join(directory, 'capture-cf.mjs'); - writeFileSync(config, JSON.stringify({ name: 'my-worker', account_id: 'test-account' })); - writeFileSync( - preload, - options?.preloadBody ?? - `import child from 'node:child_process'; -import { readFileSync, writeFileSync } from 'node:fs'; + listen, + runOperation, + temporaryDirectory, + tokenEnv, +} from '../helpers/cloudflare-operation-process.ts'; + +type CfReply = { status: number; stdout?: string; stderr?: string }; +type ApiReply = { status: number; body: unknown }; +type CfCall = { label: string; file?: string }; + +// Replaces cf with replies scripted by command prefix, served in call order. Each call is logged +// with the contents of its `--file` argument, which cf would read before the scratch file goes. +const SCRIPTED_CF = `import child from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { syncBuiltinESMExports } from 'node:module'; const originalSpawnSync = child.spawnSync.bind(child); child.spawnSync = (binary, args, options) => { - const isCfCli = Array.isArray(args) && typeof args[0] === 'string' && /(?:\\/|^)cf$/.test(args[0]); - if (!isCfCli) { + if (!Array.isArray(args) || typeof args[0] !== 'string' || !/(?:\\/|^)cf$/.test(args[0])) { return originalSpawnSync(binary, args, options); } - writeFileSync(process.env.VOID_TEST_CF_CAPTURE, JSON.stringify(args)); + const label = args.slice(1).join(' '); const fileIndex = args.indexOf('--file'); - if (fileIndex >= 0) { - writeFileSync(process.env.VOID_TEST_BULK_BODY, readFileSync(args[fileIndex + 1], 'utf8')); + const log = process.env.VOID_TEST_CALL_LOG; + const calls = existsSync(log) ? JSON.parse(readFileSync(log, 'utf8')) : []; + calls.push({ label, ...(fileIndex >= 0 ? { file: readFileSync(args[fileIndex + 1], 'utf8') } : {}) }); + writeFileSync(log, JSON.stringify(calls)); + const script = JSON.parse(readFileSync(process.env.VOID_TEST_CF_SCRIPT, 'utf8')); + const prefix = Object.keys(script).find((key) => label.startsWith(key)); + if (!prefix) { + return { status: 1, stdout: '', stderr: 'Unscripted cf call: ' + label }; } - return { status: 0, stdout: '{}', stderr: '' }; + const seen = calls.filter((call) => call.label.startsWith(prefix)).length; + const replies = script[prefix]; + return { stdout: '', stderr: '', ...replies[Math.min(seen, replies.length) - 1] }; }; syncBuiltinESMExports(); -`, - ); - const entry = join( - import.meta.dirname, - '..', - '..', - 'src', - 'cli', - 'cloudflare-operation-process.ts', - ); - const operation: CloudflareOperation = { - type: 'secret.bulk', - configPath: config, - workerName: 'my-worker', - }; - const child = spawnSync( - process.execPath, - ['--import', pathToFileURL(preload).href, entry, ...encodeCloudflareOperation(operation)], - { - env: { - ...process.env, - VOID_TEST_CF_CAPTURE: capture, - VOID_TEST_BULK_BODY: bulkBody, - VOID_TEST_CALL_LOG: callLog, - CLOUDFLARE_ACCOUNT_ID: 'test-account', - }, - encoding: 'utf8', - input, - }, - ); - const result = decodeCloudflareOperationResult({ - status: child.status, - stdout: child.stdout, - stderr: child.stderr, - }); - return { - cfArgs: existsSync(capture) - ? (JSON.parse(readFileSync(capture, 'utf8')) as Array).slice(1) - : undefined, - bulkPayload: existsSync(bulkBody) - ? (JSON.parse(readFileSync(bulkBody, 'utf8')) as unknown) - : undefined, - calls: existsSync(callLog) - ? (JSON.parse(readFileSync(callLog, 'utf8')) as Array) - : [], - result, - }; - } finally { - rmSync(directory, { recursive: true, force: true }); - } -} +`; -it('wraps Workers secrets bulk payload in a secrets object', () => { - const { cfArgs, bulkPayload, result } = runSecretBulk( - JSON.stringify({ ONE: 'first', TWO: 'second' }), +/** Run a secret operation with scripted cf replies and the Cloudflare API on a local server. */ +async function runSecretScenario(options: { + operation: CloudflareOperation; + input: string; + cf: Record>; + api?: (method: string, path: string) => ApiReply; + env?: NodeJS.ProcessEnv; +}) { + const directory = temporaryDirectory('void-cf-secret-test-'); + const apiCalls: Array<{ method: string; path: string; body: unknown }> = []; + const apiHeaders: Array = []; + const api = await listen( + createServer(async (request, response) => { + let text = ''; + for await (const chunk of request) { + text += chunk; + } + const method = request.method ?? ''; + const path = (request.url ?? '').replace('/client/v4/accounts/test-account', ''); + apiCalls.push({ method, path, body: text ? JSON.parse(text) : undefined }); + apiHeaders.push(request.headers); + const reply = options.api?.(method, path) ?? { status: 500, body: { success: false } }; + response.writeHead(reply.status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(reply.body)); + }), ); + const config = join(directory, 'wrangler.json'); + const preload = join(directory, 'scripted-cf.mjs'); + const callLog = join(directory, 'cf-calls.json'); + const script = join(directory, 'cf-script.json'); + writeFileSync(config, JSON.stringify({ name: 'my-worker', account_id: 'test-account' })); + writeFileSync(script, JSON.stringify(options.cf)); + writeFileSync(preload, SCRIPTED_CF); + const child = await runOperation( + directory, + { ...options.operation, configPath: config }, + { + ...tokenEnv(api), + VOID_TEST_CALL_LOG: callLog, + VOID_TEST_CF_SCRIPT: script, + ...options.env, + }, + preload, + options.input, + ); + const cfCalls: Array = existsSync(callLog) + ? (JSON.parse(readFileSync(callLog, 'utf8')) as Array) + : []; + return { result: decodeCloudflareOperationResult(child), cfCalls, apiCalls, apiHeaders }; +} + +const putOperation: CloudflareOperation = { + type: 'secret.put', + name: 'S1', + configPath: '', + workerName: 'my-worker', +}; +const bulkOperation: CloudflareOperation = { + type: 'secret.bulk', + configPath: '', + workerName: 'my-worker', +}; + +const ok: CfReply = { status: 0, stdout: '{}' }; +const missing: CfReply = { status: 1, stderr: '[code: 10007] This Worker does not exist.' }; +const undeployed: CfReply = { status: 1, stderr: '[code: 10013] An unknown error occurred.' }; +const success: ApiReply = { status: 200, body: { success: true, result: { id: 'v1' } } }; +const exists: ApiReply = { + status: 409, + body: { success: false, errors: [{ code: 10040, message: 'This Worker name is in use.' }] }, +}; + +/** Workers API replies keyed by request: create and version upload. */ +function workersApi(replies: { create: ApiReply; upload?: ApiReply }) { + return (method: string, path: string): ApiReply => { + if (method === 'POST' && path === '/workers/workers') { + return replies.create; + } + if (method === 'POST' && path === '/workers/workers/my-worker/versions?deploy=true') { + return replies.upload ?? { status: 500, body: {} }; + } + return { status: 500, body: {} }; + }; +} + +it('wraps Workers secrets bulk payload in a secrets object', async () => { + const { result, cfCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 'first', TWO: 'second' }), + cf: { 'workers secrets bulk': [ok] }, + }); expect(result.status, result.stderr).toBe(0); - expect(cfArgs!.slice(0, -1)).toEqual([ - 'workers', - 'secrets', - 'bulk', - '--worker', - 'my-worker', - '--file', - ]); - expect(cfArgs!.at(-1)).toMatch(/body\.json$/); - expect(bulkPayload).toEqual({ + expect(cfCalls).toHaveLength(1); + expect(cfCalls[0]!.label).toMatch( + /^workers secrets bulk --worker my-worker --file \S+body\.json$/, + ); + expect(JSON.parse(cfCalls[0]!.file!)).toEqual({ secrets: { ONE: { type: 'secret_text', text: 'first' }, TWO: { type: 'secret_text', text: 'second' }, @@ -116,39 +151,187 @@ it('wraps Workers secrets bulk payload in a secrets object', () => { }); }); -it('returns a non-zero status when secret bulk draft deploy fails after Worker 10007', () => { - const { calls, result } = runSecretBulk(JSON.stringify({ ONE: 'first' }), { - preloadBody: `import child from 'node:child_process'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { syncBuiltinESMExports } from 'node:module'; -const originalSpawnSync = child.spawnSync.bind(child); -child.spawnSync = (binary, args, options) => { - const isCfCli = Array.isArray(args) && typeof args[0] === 'string' && /(?:\\/|^)cf$/.test(args[0]); - if (!isCfCli) { - return originalSpawnSync(binary, args, options); - } - const label = args.slice(1).join(' '); - const logPath = process.env.VOID_TEST_CALL_LOG; - const calls = logPath && existsSync(logPath) - ? JSON.parse(readFileSync(logPath, 'utf8')) - : []; - calls.push(label); - if (logPath) { - writeFileSync(logPath, JSON.stringify(calls)); - } - if (label.includes('workers secrets bulk')) { - return { status: 1, stdout: '', stderr: '[10007] Worker script not found.' }; - } - if (label.includes('deploy') && label.includes('--prebuilt')) { - return { status: 1, stdout: '', stderr: 'Draft deploy failed.' }; +const isUpload = (call: { method: string; path: string }) => + call.method === 'POST' && call.path.endsWith('/versions?deploy=true'); + +it('creates a missing Worker and deploys the placeholder with its secret in one request', async () => { + const { result, cfCalls, apiCalls } = await runSecretScenario({ + operation: putOperation, + input: 'one', + cf: { 'workers secrets update': [missing] }, + api: workersApi({ create: success, upload: success }), + }); + expect(result.status, result.stderr).toBe(0); + expect(cfCalls).toHaveLength(1); + expect(apiCalls[0]).toEqual({ + method: 'POST', + path: '/workers/workers', + body: { name: 'my-worker', subdomain: { enabled: false, previews_enabled: false } }, + }); + expect(apiCalls[1]).toEqual({ + method: 'POST', + path: '/workers/workers/my-worker/versions?deploy=true', + body: { + main_module: 'index.js', + modules: [ + { + name: 'index.js', + content_type: 'application/javascript+module', + content_base64: Buffer.from(VOID_SECRET_DRAFT_SOURCE).toString('base64'), + }, + ], + compatibility_date: '2025-01-01', + bindings: [{ type: 'secret_text', name: 'S1', text: 'one' }], + annotations: { 'workers/message': VOID_SECRET_DRAFT_MESSAGE }, + }, + }); + expect(apiCalls).toHaveLength(2); +}); + +it('sets the secret on a Worker deployed after the 10007 without deploying the placeholder', async () => { + const { result, cfCalls, apiCalls } = await runSecretScenario({ + operation: putOperation, + input: 'one', + cf: { 'workers secrets update': [missing, ok] }, + api: workersApi({ create: exists }), + }); + expect(result.status, result.stderr).toBe(0); + expect(cfCalls.filter((call) => call.label.startsWith('workers secrets update'))).toHaveLength(2); + expect(apiCalls.some(isUpload)).toBe(false); +}); + +it.each([ + ['has no versions (10007)', missing], + ['has versions but no deployment (10013)', undeployed], +])('fails with the recovery steps when the existing Worker %s', async (_label, retried) => { + const { result, apiCalls } = await runSecretScenario({ + operation: putOperation, + input: 'one', + cf: { 'workers secrets update': [missing, retried] }, + api: workersApi({ create: exists, upload: success }), + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('exists but has no deployment'); + expect(result.stderr).toContain('run `void deploy` first'); + expect(result.stderr).toContain('delete the Worker "my-worker" in the Cloudflare dashboard'); + expect(apiCalls.some(isUpload)).toBe(false); +}); + +it('fails with the recovery steps for bulk secrets on a Worker without a deployment', async () => { + const { result, apiCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 'first' }), + cf: { 'workers secrets bulk': [missing, missing] }, + api: workersApi({ create: exists, upload: success }), + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('exists but has no deployment'); + expect(apiCalls.some(isUpload)).toBe(false); +}); + +it('sends the API token when a Global API Key is also set, as cf does', async () => { + const { result, apiHeaders } = await runSecretScenario({ + operation: putOperation, + input: 'one', + cf: { 'workers secrets update': [missing] }, + api: workersApi({ create: success, upload: success }), + env: { CLOUDFLARE_API_KEY: 'synthetic-key', CLOUDFLARE_EMAIL: 'a@example.test' }, + }); + expect(result.status, result.stderr).toBe(0); + expect(apiHeaders).toHaveLength(2); + for (const headers of apiHeaders) { + expect(headers.authorization).toBe('Bearer synthetic-token'); + expect(headers).not.toHaveProperty('x-auth-key'); } - return { status: 0, stdout: '{}', stderr: '' }; -}; -syncBuiltinESMExports();`, +}); + +it.each([ + ['the Worker create returns an error status', { create: { status: 500, body: {} } }], + [ + 'the Worker create is refused', + { + create: { + status: 403, + body: { success: false, errors: [{ code: 10000, message: 'Authentication error' }] }, + }, + }, + ], + [ + 'the Worker create returns an unsuccessful body', + { create: { status: 200, body: { success: false } } }, + ], + [ + 'the Worker create returns a body that is not JSON', + { create: { status: 200, body: 'created' } }, + ], +])('fails without deploying when %s', async (_label, replies) => { + const { result, apiCalls } = await runSecretScenario({ + operation: putOperation, + input: 'one', + cf: { 'workers secrets update': [missing, missing] }, + api: workersApi(replies), + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Cloudflare did not create the Worker'); + expect(apiCalls.some(isUpload)).toBe(false); +}); + +it('returns a non-zero status when the placeholder deploy fails after Worker 10007', async () => { + const { result, apiCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 'first' }), + cf: { 'workers secrets bulk': [missing] }, + api: workersApi({ + create: success, + upload: { + status: 400, + body: { + success: false, + errors: [{ code: 10021, message: 'Invalid compatibility_date "2025-13-01".' }], + }, + }, + }), + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Cloudflare did not deploy the secret placeholder (HTTP 400).'); + expect(result.stderr).toContain('code 10021: Invalid compatibility_date "2025-13-01".'); + expect(apiCalls.filter(isUpload)).toHaveLength(1); +}); + +it('deploys every bulk secret with the placeholder', async () => { + const { result, apiCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 'first', TWO: 'second' }), + cf: { 'workers secrets bulk': [missing] }, + api: workersApi({ create: success, upload: success }), + }); + expect(result.status, result.stderr).toBe(0); + expect(apiCalls.find(isUpload)?.body).toHaveProperty('bindings', [ + { type: 'secret_text', name: 'ONE', text: 'first' }, + { type: 'secret_text', name: 'TWO', text: 'second' }, + ]); +}); + +it('sets bulk secrets on a Worker deployed after the 10007 without deploying the placeholder', async () => { + const { result, cfCalls, apiCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 'first' }), + cf: { 'workers secrets bulk': [missing, ok] }, + api: workersApi({ create: exists }), + }); + expect(result.status, result.stderr).toBe(0); + expect(cfCalls.filter((call) => call.label.startsWith('workers secrets bulk'))).toHaveLength(2); + expect(apiCalls.some(isUpload)).toBe(false); +}); + +it('rejects bulk secret values that are not strings', async () => { + const { result, apiCalls, cfCalls } = await runSecretScenario({ + operation: bulkOperation, + input: JSON.stringify({ ONE: 1 }), + cf: {}, }); expect(result.status).toBe(1); - expect(result.stderr).toContain('Draft deploy failed.'); - expect(calls.some((call) => call.includes('workers secrets bulk'))).toBe(true); - expect(calls.some((call) => call.includes('void:secret-draft'))).toBe(true); - expect(calls.filter((call) => call.includes('workers secrets bulk'))).toHaveLength(1); + expect(result.stderr).toContain('Secret values must be strings.'); + expect(cfCalls).toHaveLength(0); + expect(apiCalls).toHaveLength(0); }); diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 68e1da29b..b5bc35638 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -1,79 +1,29 @@ -import { execFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { createServer, request as httpRequest, type Server } from 'node:http'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer, request as httpRequest } from 'node:http'; import type { AddressInfo } from 'node:net'; import { connect } from 'node:net'; -import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { Duplex } from 'node:stream'; -import { pathToFileURL } from 'node:url'; -import type { CloudflareOperation } from '@void/deploy-cloudflare'; -import { afterEach, expect, it } from 'vite-plus/test'; +import { expect, it } from 'vite-plus/test'; import { isVoidSecretOnlyDraft } from '../../../deploy-cloudflare/src/secret-draft.ts'; import { voidSecretDraftVersion } from '../../../deploy-cloudflare/test/secret-draft-fixture.ts'; -import { - decodeCloudflareOperationResult, - encodeCloudflareOperation, -} from '../../src/cli/cloudflare-process.ts'; +import { decodeCloudflareOperationResult } from '../../src/cli/cloudflare-process.ts'; import { Buffer } from '../../src/cli/node-buffer.ts'; -import { resolveCloudflareOperationArgv } from '../../src/cli/preset.ts'; +import { + cleanups, + listen, + runOperation, + systemEnv, + temporaryDirectory, + tokenEnv, +} from '../helpers/cloudflare-operation-process.ts'; const voidVersion = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) .version as string; const cfVersion = JSON.parse(readFileSync(new URL(import.meta.resolve('cf/package.json')), 'utf8')) .version as string; -const cleanups: Array<() => unknown> = []; -afterEach(async () => { - for (const cleanup of cleanups.splice(0).reverse()) { - await cleanup(); - } -}); - -function temporaryDirectory(prefix: string): string { - const directory = mkdtempSync(join(tmpdir(), prefix)); - cleanups.push(() => rmSync(directory, { recursive: true, force: true })); - return directory; -} - -/** Serve on a random local port until the test ends and return the origin. */ -async function listen(server: Server): Promise { - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - cleanups.push( - () => - new Promise((resolve) => { - server.close(() => resolve()); - server.closeAllConnections(); - }), - ); - return `http://127.0.0.1:${(server.address() as AddressInfo).port}`; -} - -// Children get only what Node needs, so host credentials cannot leak into a test. -const systemEnv = { - PATH: process.env.PATH, - SYSTEMROOT: process.env.SYSTEMROOT, - WINDIR: process.env.WINDIR, - TMP: process.env.TMP, - TEMP: process.env.TEMP, - NODE_COMPILE_CACHE: process.env.NODE_COMPILE_CACHE, - NODE_DISABLE_COMPILE_CACHE: process.env.NODE_DISABLE_COMPILE_CACHE, -}; - -function tokenEnv( - api?: string, - token: 'CLOUDFLARE_API_TOKEN' | 'CF_API_TOKEN' = 'CLOUDFLARE_API_TOKEN', -): NodeJS.ProcessEnv { - return { - ...systemEnv, - CI: 'true', - [token]: 'synthetic-token', - CLOUDFLARE_ACCOUNT_ID: 'test-account', - ...(api ? { CLOUDFLARE_API_BASE_URL: `${api}/client/v4` } : {}), - }; -} - function unproxiedEnv(api: string): NodeJS.ProcessEnv { return { ...process.env, @@ -87,26 +37,6 @@ function unproxiedEnv(api: string): NodeJS.ProcessEnv { }; } -/** Run the operation process the CLI spawns; `preload` is imported first to stub cf. */ -function runOperation( - cwd: string, - request: CloudflareOperation | Array, - env: NodeJS.ProcessEnv, - preload?: string, -): Promise<{ status: number; stdout: string; stderr: string }> { - const [command, ...prefix] = resolveCloudflareOperationArgv(); - const args = Array.isArray(request) ? request : encodeCloudflareOperation(request); - return new Promise((resolve) => { - execFile( - command!, - [...(preload ? ['--import', pathToFileURL(preload).href] : []), ...prefix, ...args], - { cwd, encoding: 'utf8', timeout: 20_000, maxBuffer: 128 * 1024 * 1024, env }, - (error, stdout, stderr) => - resolve({ status: error ? Number(error.code) || 1 : 0, stdout, stderr }), - ); - }); -} - it.each([ { observability: { enabled: true } }, { observability: { enabled: false } },