From f837c7fb9710d01d65426b3c5527dd411f22341f Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Thu, 8 May 2025 14:43:29 -0400 Subject: [PATCH] fix(client): Properly set `temporal-namespace` header on gRPC requests --- .github/workflows/ci.yml | 53 ++++--- .github/workflows/release.yml | 17 ++- packages/client/src/connection.ts | 38 ++++- .../test/src/test-client-cloud-operations.ts | 31 ---- packages/test/src/test-client-connection.ts | 48 +++++- .../src/test-native-connection-headers.ts | 55 +++++++ packages/test/src/test-temporal-cloud.ts | 144 ++++++++++++++++++ 7 files changed, 323 insertions(+), 63 deletions(-) delete mode 100644 packages/test/src/test-client-cloud-operations.ts create mode 100644 packages/test/src/test-temporal-cloud.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48ea908fc..a3a3be95b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -205,25 +205,28 @@ jobs: --sqlite-pragma synchronous=OFF \ --headless &> ./devserver.log & - # We write the certs to disk because it serves the sample. Written into /tmp/temporal-certs - - name: Create certs dir - run: node scripts/create-certs-dir.js ${{ steps.tmp-dir.outputs.dir }}/certs - if: ${{ vars.TEMPORAL_CLIENT_NAMESPACE != '' }} - env: - TEMPORAL_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }} - TEMPORAL_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }} - - name: Run Tests run: npm run test env: RUN_INTEGRATION_TESTS: true REUSE_V8_CONTEXT: ${{ matrix.reuse-v8-context }} - # Cloud Tests will be skipped if TEMPORAL_CLIENT_CLOUD_API_KEY is left empty - TEMPORAL_CLOUD_SAAS_ADDRESS: ${{ vars.TEMPORAL_CLOUD_SAAS_ADDRESS || 'saas-api.tmprl.cloud:443' }} - TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_CLIENT_CLOUD_API_VERSION: 2024-05-13-00 - TEMPORAL_CLIENT_CLOUD_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + # For Temporal Cloud + mTLS tests + TEMPORAL_CLOUD_MTLS_TEST_TARGET_HOST: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_CLOUD_MTLS_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_CLOUD_MTLS_TEST_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }} + TEMPORAL_CLOUD_MTLS_TEST_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }} + + # For Temporal Cloud + API key tests + TEMPORAL_CLOUD_API_KEY_TEST_TARGET_HOST: us-west-2.aws.api.temporal.io:7233 + TEMPORAL_CLOUD_API_KEY_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_CLOUD_API_KEY_TEST_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + + # For Temporal Cloud + Cloud Ops tests + TEMPORAL_CLOUD_OPS_TEST_TARGET_HOST: saas-api.tmprl.cloud:443 + TEMPORAL_CLOUD_OPS_TEST_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_CLOUD_OPS_TEST_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLOUD_OPS_TEST_API_VERSION: 2024-05-13-00 # FIXME: Move samples tests to a custom activity # Sample 1: hello-world to local server @@ -234,17 +237,30 @@ jobs: # Sample 2: hello-world-mtls to cloud server - name: Instantiate sample project using verdaccio artifacts - Hello World MTLS - if: ${{ vars.TEMPORAL_CLIENT_NAMESPACE != '' }} run: | + if [ -z "$TEMPORAL_ADDRESS" ] || [ -z "$TEMPORAL_NAMESPACE" ] || [ -z "$TEMPORAL_CLIENT_CERT" ] || [ -z "$TEMPORAL_CLIENT_KEY" ]; then + echo "Skipping hello-world-mtls sample test as required environment variables are not set" + exit 0 + fi + + node scripts/create-certs-dir.js ${{ steps.tmp-dir.outputs.dir }}/certs node scripts/init-from-verdaccio.js --registry-dir ${{ steps.tmp-dir.outputs.dir }}/npm-registry --sample https://github.com/temporalio/samples-typescript/tree/main/hello-world-mtls --target-dir ${{ steps.tmp-dir.outputs.dir }}/sample-hello-world-mtls node scripts/test-example.js --work-dir "${{ steps.tmp-dir.outputs.dir }}/sample-hello-world-mtls" env: # These env vars are used by the hello-world-mtls sample - TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }} + TEMPORAL_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }} + TEMPORAL_TASK_QUEUE: ${{ format('tssdk-ci-{0}-{1}-sample-hello-world-mtls-{2}-{3}', matrix.platform, matrix.node, github.run_id, github.run_attempt) }} + TEMPORAL_CLIENT_CERT_PATH: ${{ steps.tmp-dir.outputs.dir }}/certs/client.pem TEMPORAL_CLIENT_KEY_PATH: ${{ steps.tmp-dir.outputs.dir }}/certs/client.key - TEMPORAL_TASK_QUEUE: ${{ format('{0}-{1}-sample-hello-world-mtls', matrix.platform, matrix.node) }} + + - name: Destroy certs dir + if: always() + run: rm -rf ${{ steps.tmp-dir.outputs.dir }}/certs + continue-on-error: true # Sample 3: fetch-esm to local server - name: Instantiate sample project using verdaccio artifacts - Fetch ESM @@ -254,11 +270,6 @@ jobs: # End samples - - name: Destroy certs dir - if: always() - run: rm -rf ${{ steps.tmp-dir.outputs.dir }}/certs - continue-on-error: true - - name: Upload NPM logs uses: actions/upload-artifact@v4 if: failure() || cancelled() diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 13e791d3c..f506988b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -304,6 +304,12 @@ jobs: shell: bash run: node scripts/create-certs-dir.js "${{ runner.temp }}/certs" if: matrix.server == 'cloud' + env: + # These env vars are used by the hello-world-mtls sample + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} + TEMPORAL_CLIENT_CERT: ${{ secrets.TEMPORAL_CLIENT_CERT }} + TEMPORAL_CLIENT_KEY: ${{ secrets.TEMPORAL_CLIENT_KEY }} - name: Test run a workflow (non-cloud) run: node scripts/test-example.js --work-dir "${{ runner.temp }}/example" @@ -311,16 +317,19 @@ jobs: if: matrix.server == 'cli' - name: Test run a workflow (cloud) - run: node scripts/test-example.js --work-dir "${{ runner.temp }}/example" + if: matrix.server == 'cloud' + run: | + # The required environment variables must be present for releases (this must be run from the official repo) + node scripts/create-certs-dir.js ${{ steps.tmp-dir.outputs.dir }}/certs + node scripts/test-example.js --work-dir "${{ runner.temp }}/example" shell: bash env: # These env vars are used by the hello-world-mtls sample - TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud + TEMPORAL_ADDRESS: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }}.tmprl.cloud:7233 TEMPORAL_NAMESPACE: ${{ vars.TEMPORAL_CLIENT_NAMESPACE }} TEMPORAL_CLIENT_CERT_PATH: ${{ runner.temp }}/certs/client.pem TEMPORAL_CLIENT_KEY_PATH: ${{ runner.temp }}/certs/client.key - TEMPORAL_TASK_QUEUE: ${{ format('{0}-{1}-{2}', matrix.platform, matrix.node, matrix.sample) }} - if: matrix.server == 'cloud' + TEMPORAL_TASK_QUEUE: ${{ format('tssdk-ci-{0}-{1}-sample-hello-world-mtls-{2}-{3}', matrix.platform, matrix.node, github.run_id, github.run_attempt) }} - name: Destroy certs dir if: always() diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 5427afc46..6d089cec7 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import * as grpc from '@grpc/grpc-js'; -import type { RPCImpl } from 'protobufjs'; +import type * as proto from 'protobufjs'; import { filterNullAndUndefined, normalizeTlsConfig, @@ -8,6 +8,7 @@ import { normalizeGrpcEndpointAddress, } from '@temporalio/common/lib/internal-non-workflow'; import { Duration, msOptionalToNumber } from '@temporalio/common/lib/time'; +import { type temporal } from '@temporalio/proto'; import { isGrpcServiceError, ServiceError } from './errors'; import { defaultGrpcRetryOptions, makeGrpcRetryInterceptor } from './grpc-retry'; import pkg from './pkg'; @@ -419,7 +420,7 @@ export class Connection { }: ConnectionCtorOptions) { this.options = options; this.client = client; - this.workflowService = workflowService; + this.workflowService = this.withNamespaceHeaderInjector(workflowService); this.operatorService = operatorService; this.healthService = healthService; this.callContextStorage = callContextStorage; @@ -433,8 +434,12 @@ export class Connection { interceptors, staticMetadata, apiKeyFnRef, - }: RPCImplOptions): RPCImpl { - return (method: { name: string }, requestData: any, callback: grpc.requestCallback) => { + }: RPCImplOptions): proto.RPCImpl { + return ( + method: proto.Method | proto.rpc.ServiceMethod, proto.Message>, + requestData: Uint8Array, + callback: grpc.requestCallback + ) => { const metadataContainer = new grpc.Metadata(); const { metadata, deadline, abortSignal } = callContextStorage.getStore() ?? {}; if (apiKeyFnRef.fn) { @@ -449,6 +454,7 @@ export class Connection { metadataContainer.set(k, v); } } + const call = client.makeUnaryRequest( `/${serviceName}/${method.name}`, (arg: any) => arg, @@ -458,6 +464,7 @@ export class Connection { { interceptors, deadline }, callback ); + if (abortSignal != null) { abortSignal.addEventListener('abort', () => call.cancel()); } @@ -507,6 +514,8 @@ export class Connection { * * @see https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal */ + // FIXME: `abortSignal` should be cumulative, i.e. if a signal is already set, it should be added + // to the list of signals, and both the new and existing signal should abort the request. async withAbortSignal(abortSignal: AbortSignal, fn: () => Promise): Promise { const cc = this.callContextStorage.getStore(); return await this.callContextStorage.run({ ...cc, abortSignal }, fn); @@ -605,4 +614,25 @@ export class Connection { this.client.close(); this.callContextStorage.disable(); } + + private withNamespaceHeaderInjector( + workflowService: temporal.api.workflowservice.v1.WorkflowService + ): temporal.api.workflowservice.v1.WorkflowService { + const wrapper: any = {}; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type + for (const [methodName, methodImpl] of Object.entries(workflowService) as [string, Function][]) { + if (typeof methodImpl !== 'function') continue; + + wrapper[methodName] = (...args: any[]) => { + const namespace = args[0]?.namespace; + if (namespace) { + return this.withMetadata({ 'temporal-namespace': namespace }, () => methodImpl.apply(workflowService, args)); + } else { + return methodImpl.apply(workflowService, args); + } + }; + } + return wrapper as WorkflowService; + } } diff --git a/packages/test/src/test-client-cloud-operations.ts b/packages/test/src/test-client-cloud-operations.ts deleted file mode 100644 index 7f5619402..000000000 --- a/packages/test/src/test-client-cloud-operations.ts +++ /dev/null @@ -1,31 +0,0 @@ -import test from 'ava'; -import { Metadata } from '@temporalio/client'; -import { CloudOperationsClient, CloudOperationsConnection } from '@temporalio/cloud'; - -test('Can create connection to Temporal Cloud Operation service', async (t) => { - const address = process.env.TEMPORAL_CLOUD_SAAS_ADDRESS ?? 'saas-api.tmprl.cloud:443'; - const apiKey = process.env.TEMPORAL_CLIENT_CLOUD_API_KEY; - const apiVersion = process.env.TEMPORAL_CLIENT_CLOUD_API_VERSION; - const namespace = process.env.TEMPORAL_CLIENT_CLOUD_NAMESPACE; - - if (!apiKey) { - t.pass('Skipping: No Cloud API key provided'); - return; - } - - const connection = await CloudOperationsConnection.connect({ - address, - apiKey, - }); - const client = new CloudOperationsClient({ connection, apiVersion }); - - const metadata: Metadata = {}; - if (apiVersion) { - metadata['temporal-cloud-api-version'] = apiVersion; - } - - const response = await client.withMetadata(metadata, async () => { - return client.cloudService.getNamespace({ namespace }); - }); - t.is(response?.namespace?.namespace, namespace); -}); diff --git a/packages/test/src/test-client-connection.ts b/packages/test/src/test-client-connection.ts index ccc3872dd..fd29534ec 100644 --- a/packages/test/src/test-client-connection.ts +++ b/packages/test/src/test-client-connection.ts @@ -1,8 +1,9 @@ import { fork } from 'node:child_process'; import * as http2 from 'node:http2'; -import util from 'node:util'; -import path from 'node:path'; -import fs from 'node:fs/promises'; +import * as util from 'node:util'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import assert from 'node:assert'; import test, { TestFn } from 'ava'; import * as grpc from '@grpc/grpc-js'; import * as protoLoader from '@grpc/proto-loader'; @@ -131,6 +132,47 @@ test('withMetadata / withDeadline / withAbortSignal set the CallContext for RPC t.true(isGrpcCancelledError(err)); }); +test('apiKey sets temporal-namespace header appropriately', async (t) => { + let getSystemInfoHeaders: grpc.Metadata = new grpc.Metadata(); + let startWorkflowExecutionHeaders: grpc.Metadata = new grpc.Metadata(); + + const server = new grpc.Server(); + server.addService(workflowServiceProtoDescriptor.temporal.api.workflowservice.v1.WorkflowService.service, { + getSystemInfo( + call: grpc.ServerUnaryCall< + temporal.api.workflowservice.v1.IGetSystemInfoRequest, + temporal.api.workflowservice.v1.IGetSystemInfoResponse + >, + callback: grpc.sendUnaryData + ) { + getSystemInfoHeaders = call.metadata.clone(); + callback(null, {}); + }, + startWorkflowExecution(call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) { + startWorkflowExecutionHeaders = call.metadata.clone(); + callback(null, {}); + }, + }); + const port = await bindLocalhost(server); + const conn = await Connection.connect({ + address: `127.0.0.1:${port}`, + metadata: { staticKey: 'set' }, + apiKey: 'test-token', + }); + + await conn.workflowService.startWorkflowExecution({ namespace: 'test-namespace' }); + + assert(getSystemInfoHeaders !== undefined); + t.deepEqual(getSystemInfoHeaders.get('temporal-namespace'), []); + t.deepEqual(getSystemInfoHeaders.get('authorization'), ['Bearer test-token']); + t.deepEqual(getSystemInfoHeaders.get('staticKey'), ['set']); + + assert(startWorkflowExecutionHeaders); + t.deepEqual(startWorkflowExecutionHeaders.get('temporal-namespace'), ['test-namespace']); + t.deepEqual(startWorkflowExecutionHeaders.get('authorization'), ['Bearer test-token']); + t.deepEqual(startWorkflowExecutionHeaders.get('staticKey'), ['set']); +}); + test('Connection can connect using "[ipv6]:port" address', async (t) => { let gotRequest = false; const server = new grpc.Server(); diff --git a/packages/test/src/test-native-connection-headers.ts b/packages/test/src/test-native-connection-headers.ts index 9778c97e7..db466930d 100644 --- a/packages/test/src/test-native-connection-headers.ts +++ b/packages/test/src/test-native-connection-headers.ts @@ -1,5 +1,6 @@ import util from 'node:util'; import path from 'node:path'; +import assert from 'node:assert'; import test from 'ava'; import { Subject, firstValueFrom, skip } from 'rxjs'; import * as grpc from '@grpc/grpc-js'; @@ -8,6 +9,19 @@ import { NativeConnection } from '@temporalio/worker'; import { temporal } from '@temporalio/proto'; import { Worker } from './helpers'; +const workflowServicePackageDefinition = protoLoader.loadSync( + path.resolve( + __dirname, + '../../core-bridge/sdk-core/sdk-core-protos/protos/api_upstream/temporal/api/workflowservice/v1/service.proto' + ), + { includeDirs: [path.resolve(__dirname, '../../core-bridge/sdk-core/sdk-core-protos/protos/api_upstream')] } +); +const workflowServiceProtoDescriptor = grpc.loadPackageDefinition(workflowServicePackageDefinition) as any; + +async function bindLocalhost(server: grpc.Server): Promise { + return await util.promisify(server.bindAsync.bind(server))('127.0.0.1:0', grpc.ServerCredentials.createInsecure()); +} + test('NativeConnection passes headers provided in options', async (t) => { const packageDefinition = protoLoader.loadSync( path.resolve( @@ -89,3 +103,44 @@ test('NativeConnection passes headers provided in options', async (t) => { }); await Promise.all([firstValueFrom(newValuesSubject.pipe(skip(1))).then(() => worker.shutdown()), worker.run()]); }); + +test('apiKey sets temporal-namespace header appropriately', async (t) => { + let getSystemInfoHeaders: grpc.Metadata = new grpc.Metadata(); + let startWorkflowExecutionHeaders: grpc.Metadata = new grpc.Metadata(); + + const server = new grpc.Server(); + server.addService(workflowServiceProtoDescriptor.temporal.api.workflowservice.v1.WorkflowService.service, { + getSystemInfo( + call: grpc.ServerUnaryCall< + temporal.api.workflowservice.v1.IGetSystemInfoRequest, + temporal.api.workflowservice.v1.IGetSystemInfoResponse + >, + callback: grpc.sendUnaryData + ) { + getSystemInfoHeaders = call.metadata.clone(); + callback(null, {}); + }, + startWorkflowExecution(call: grpc.ServerUnaryCall, callback: grpc.sendUnaryData) { + startWorkflowExecutionHeaders = call.metadata.clone(); + callback(null, {}); + }, + }); + const port = await bindLocalhost(server); + const conn = await NativeConnection.connect({ + address: `127.0.0.1:${port}`, + metadata: { staticKey: 'set' }, + apiKey: 'test-token', + }); + + await conn.workflowService.startWorkflowExecution({ namespace: 'test-namespace' }); + + assert(getSystemInfoHeaders !== undefined); + t.deepEqual(getSystemInfoHeaders.get('temporal-namespace'), []); + t.deepEqual(getSystemInfoHeaders.get('authorization'), ['Bearer test-token']); + t.deepEqual(getSystemInfoHeaders.get('staticKey'), ['set']); + + assert(startWorkflowExecutionHeaders); + t.deepEqual(startWorkflowExecutionHeaders.get('temporal-namespace'), ['test-namespace']); + t.deepEqual(startWorkflowExecutionHeaders.get('authorization'), ['Bearer test-token']); + t.deepEqual(startWorkflowExecutionHeaders.get('staticKey'), ['set']); +}); diff --git a/packages/test/src/test-temporal-cloud.ts b/packages/test/src/test-temporal-cloud.ts new file mode 100644 index 000000000..dde679bd5 --- /dev/null +++ b/packages/test/src/test-temporal-cloud.ts @@ -0,0 +1,144 @@ +import { randomUUID } from 'node:crypto'; +import test from 'ava'; +import { Client, Connection, Metadata } from '@temporalio/client'; +import { CloudOperationsClient, CloudOperationsConnection } from '@temporalio/cloud'; +import { NativeConnection, Worker } from '@temporalio/worker'; +import * as workflows from './workflows'; + +test('Can connect to Temporal Cloud using mTLS', async (t) => { + const address = process.env.TEMPORAL_CLOUD_MTLS_TEST_TARGET_HOST; + const namespace = process.env.TEMPORAL_CLOUD_MTLS_TEST_NAMESPACE; + const clientCert = process.env.TEMPORAL_CLOUD_MTLS_TEST_CLIENT_CERT; + const clientKey = process.env.TEMPORAL_CLOUD_MTLS_TEST_CLIENT_KEY; + + if (!address || !namespace || !clientCert || !clientKey) { + t.pass('Skipping: No Temporal Cloud mTLS connection details provided'); + return; + } + + const connection = await Connection.connect({ + address, + tls: { + clientCertPair: { + crt: Buffer.from(clientCert), + key: Buffer.from(clientKey), + }, + }, + }); + const client = new Client({ connection, namespace }); + + const nativeConnection = await NativeConnection.connect({ + address, + tls: { + clientCertPair: { + crt: Buffer.from(clientCert), + key: Buffer.from(clientKey), + }, + }, + }); + const nativeClient = new Client({ connection: nativeConnection, namespace }); + + const taskQueue = `test-temporal-cloud-mtls-${randomUUID()}`; + const worker = await Worker.create({ + namespace, + workflowsPath: require.resolve('./workflows'), + connection: nativeConnection, + taskQueue, + }); + + const [res1, res2] = await worker.runUntil(async () => { + return Promise.all([ + client.workflow.execute(workflows.successString, { + workflowId: randomUUID(), + taskQueue, + }), + nativeClient.workflow.execute(workflows.successString, { + workflowId: randomUUID(), + taskQueue, + }), + ]); + }); + + t.is(res1, 'success'); + t.is(res2, 'success'); +}); + +test('Can connect to Temporal Cloud using API Keys', async (t) => { + const address = process.env.TEMPORAL_CLOUD_API_KEY_TEST_TARGET_HOST; + const namespace = process.env.TEMPORAL_CLOUD_API_KEY_TEST_NAMESPACE; + const apiKey = process.env.TEMPORAL_CLOUD_API_KEY_TEST_API_KEY; + + if (!address || !namespace || !apiKey) { + t.pass('Skipping: No Temporal Cloud API Key connection details provided'); + return; + } + + const connection = await Connection.connect({ + address, + apiKey, + tls: true, + }); + const client = new Client({ connection, namespace }); + + const nativeConnection = await NativeConnection.connect({ + address, + apiKey, + tls: true, + }); + const nativeClient = new Client({ connection: nativeConnection, namespace }); + + const taskQueue = `test-temporal-cloud-api-key-${randomUUID()}`; + const worker = await Worker.create({ + namespace, + workflowsPath: require.resolve('./workflows'), + connection: nativeConnection, + taskQueue, + }); + + const [res1, res2] = await worker.runUntil(async () => { + return Promise.all([ + client.workflow.execute(workflows.successString, { + workflowId: randomUUID(), + taskQueue, + }), + nativeClient.workflow.execute(workflows.successString, { + workflowId: randomUUID(), + taskQueue, + }), + ]); + }); + + t.is(res1, 'success'); + t.is(res2, 'success'); +}); + +test('Can create connection to Temporal Cloud Operation service', async (t) => { + const address = process.env.TEMPORAL_CLOUD_OPS_TEST_TARGET_HOST; + const namespace = process.env.TEMPORAL_CLOUD_OPS_TEST_NAMESPACE; + const apiKey = process.env.TEMPORAL_CLOUD_OPS_TEST_API_KEY; + const apiVersion = process.env.TEMPORAL_CLOUD_OPS_TEST_API_VERSION; + + if (!address || !namespace || !apiKey || !apiVersion) { + t.pass('Skipping: No Cloud Operations connection details provided'); + return; + } + + const connection = await CloudOperationsConnection.connect({ + address, + apiKey, + }); + const client = new CloudOperationsClient({ connection, apiVersion }); + + const metadata: Metadata = {}; + if (apiVersion) { + metadata['temporal-cloud-api-version'] = apiVersion; + } + + // Note that the Cloud Operations client does not automatically inject the namespace header. + // This is intentional, as the Cloud Operations Client is a temporary API and will be moved + // to a different owner package in the near future. + const response = await client.withMetadata(metadata, async () => { + return client.cloudService.getNamespace({ namespace }); + }); + t.is(response?.namespace?.namespace, namespace); +});