Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f797f5a
feat(pqc): add post quantum cryptography safe showcase integration tests
google-labs-jules[bot] Jul 14, 2026
754f7af
feat(pqc): add post quantum cryptography safe showcase integration tests
google-labs-jules[bot] Jul 14, 2026
9413c6a
Comment out gapic tests
danieljbruce Jul 14, 2026
2a3c991
Make the server private
danieljbruce Jul 14, 2026
c050585
Refactor into PQC compliance tests
danieljbruce Jul 14, 2026
debed48
Add some JS doc comments
danieljbruce Jul 15, 2026
5f1a584
Add line for the reason we change the directory
danieljbruce Jul 15, 2026
f458d3d
Add a comment explaining why we wait
danieljbruce Jul 15, 2026
287333a
Move the pqc compliance tests to separate file
danieljbruce Jul 15, 2026
24d0969
Add a jsdoc comment
danieljbruce Jul 15, 2026
96d231f
Add license header
danieljbruce Jul 15, 2026
3d9146f
Add the jest framework to the google-gax repository
danieljbruce Jul 15, 2026
c7f0c97
Merge branch 'main' of https://github.com/googleapis/google-cloud-nod…
danieljbruce Jul 15, 2026
17ec0ac
Eliminate the reference to the original working dir
danieljbruce Jul 15, 2026
fe5df38
Eliminate added comment for the main function
danieljbruce Jul 15, 2026
1a4ddb4
Don’t comment out all the other tests
danieljbruce Jul 16, 2026
ff7c90c
Add a comment about the reason a separate showcase server test is req…
danieljbruce Jul 16, 2026
ac2a911
Remove the console logs to reduce clutter
danieljbruce Jul 16, 2026
9612e84
Add headers
danieljbruce Jul 16, 2026
b1b54cc
Test against exact cipher and protocol
danieljbruce Jul 16, 2026
d24604b
Update core/packages/gax/test/test-application/src/pqc-test.ts
danieljbruce Jul 16, 2026
7bf096f
Update core/packages/gax/test/test-application/src/index.ts
danieljbruce Jul 16, 2026
06adb75
Update core/packages/gax/test/showcase-server/src/index.ts
danieljbruce Jul 16, 2026
1a6227a
Merge branch 'pqc-showcase-tests' of https://github.com/googleapis/go…
danieljbruce Jul 16, 2026
305b50c
Compile jest and node
danieljbruce Jul 16, 2026
a2ccbd4
Check the tls-client-supported-groups metadata too
danieljbruce Jul 22, 2026
3320e5d
Remove cipher comparison
danieljbruce Jul 22, 2026
09b0c7e
Skip this test conditionally
danieljbruce Jul 22, 2026
fdd3586
Move the pem code to the server
danieljbruce Jul 22, 2026
bfc17ef
Add assert statement for the socket port
danieljbruce Jul 22, 2026
5b9e265
Assertion statement added
danieljbruce Jul 22, 2026
2c920c8
Remove the addition of jest
danieljbruce Jul 22, 2026
bcd6632
Implement environment variable and temp dir
danieljbruce Jul 22, 2026
6a4aa1d
remove the TLS check
danieljbruce Jul 22, 2026
53131af
Merge branch 'main' into pqc-showcase-tests
danieljbruce Jul 22, 2026
fc1fa41
Add explicit return undefined
danieljbruce Jul 22, 2026
35c1adb
Merge branch 'pqc-showcase-tests' of https://github.com/googleapis/go…
danieljbruce Jul 22, 2026
e27adef
Add a watcher instead of a loop
danieljbruce Jul 23, 2026
03cb81b
separate grpc and rest functions
danieljbruce Jul 23, 2026
6e12527
Add one line comment
danieljbruce Jul 23, 2026
0d13a4c
Add delay for watcher
danieljbruce Jul 23, 2026
a7ee02f
Merge branch 'main' into pqc-showcase-tests
danieljbruce Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 64 additions & 5 deletions core/packages/gax/test/showcase-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,48 @@ function sleep(timeoutMs: number) {
export class ShowcaseServer {
server: execa.ExecaChildProcess | undefined;

async start() {
const testDir = path.join(process.cwd(), '.showcase-server-dir');
/**
* Starts the gapic-showcase server.
* @param opts Optional configuration for the server:
* - tls: If true, starts the server with TLS enabled.
* - port: The port to bind the server to (e.g. ":7443").
* - caCertOutputFile: Path where the server should write its CA cert (when TLS is enabled).
*/
async start(opts?: { tls?: boolean; port?: string; caCertOutputFile?: string }) {
const cwd = process.cwd();
const testDir = path.join(cwd, '.showcase-server-dir');
const platform = process.platform;
const arch = process.arch === 'x64' ? 'amd64' : process.arch;
const showcaseVersion = process.env['SHOWCASE_VERSION'] || '0.36.2';
const tarballFilename = `gapic-showcase-${showcaseVersion}-${platform}-${arch}.tar.gz`;
const fallbackServerUrl = `https://github.com/googleapis/gapic-showcase/releases/download/v${showcaseVersion}/${tarballFilename}`;
const binaryName = './gapic-showcase';

let resolvedCaCertPath = '';
if (opts?.caCertOutputFile) {
resolvedCaCertPath = path.resolve(cwd, opts.caCertOutputFile);
}

await fsp.rm(testDir, {recursive: true, force: true});
await mkdir(testDir);
process.chdir(testDir);
console.log(`Server will be run from ${testDir}.`);

await download(fallbackServerUrl, testDir);
await execa('tar', ['xzf', tarballFilename]);
const childProcess = execa(binaryName, ['run'], {
await execa('tar', ['xzf', tarballFilename], {cwd: testDir});

const args = ['run'];
// Pass additional options to gapic-showcase based on opts
if (opts?.tls) {
args.push('--tls');
}
if (opts?.port) {
args.push('--port', opts.port);
}
if (resolvedCaCertPath) {
args.push('--ca-cert-output-file', resolvedCaCertPath);
}

const childProcess = execa(binaryName, args, {
cwd: testDir,
stdio: 'inherit',
});
Expand All @@ -68,6 +93,40 @@ export class ShowcaseServer {
);

this.server = childProcess;

if (resolvedCaCertPath) {
const dir = path.dirname(resolvedCaCertPath);
const filename = path.basename(resolvedCaCertPath);

const pemExists = await new Promise<boolean>((resolve) => {
if (fs.existsSync(resolvedCaCertPath)) {
return resolve(true);
}

const timeoutId = setTimeout(() => {
watcher.close();
resolve(false);
}, 15000);

const watcher = fs.watch(dir, (eventType, triggeredFilename) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if this triggers early (before showcase has written) it might read a partial file. Consider a few ms wait here with a comment to give it some time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

if (triggeredFilename === filename || fs.existsSync(resolvedCaCertPath)) {
clearTimeout(timeoutId);
watcher.close();
// Wait a few milliseconds to ensure showcase has finished writing the file.
setTimeout(() => {
resolve(true);
}, 500);
}
});
});

if (!pemExists) {
throw new Error(`CA Certificate file not found at ${resolvedCaCertPath}`);
}

return fs.readFileSync(resolvedCaCertPath);
}
return undefined;
}

stop() {
Expand Down
3 changes: 3 additions & 0 deletions core/packages/gax/test/test-application/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {EchoClient, SequenceServiceClient, protos} from 'showcase-echo-client';
import {ShowcaseServer} from 'showcase-server';
import * as assert from 'assert';
import {promises as fsp} from 'fs';
import {runPqcComplianceTests} from './pqc-test';
import * as path from 'path';
import {
protobuf,
Expand Down Expand Up @@ -2910,6 +2911,8 @@ async function main() {
} finally {
showcaseServer.stop();
}
// Run PQC tests with a different showcase server because setup with a certificate is required.
await runPqcComplianceTests()
}

main();
211 changes: 211 additions & 0 deletions core/packages/gax/test/test-application/src/pqc-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as fs from 'fs';
import * as https from 'https';
import * as path from 'path';
import * as assert from 'assert';
import {grpc, GoogleAuth, googleAuthLibrary} from 'google-gax';
import {EchoClient} from 'showcase-echo-client';
import {ShowcaseServer} from 'showcase-server';

import * as os from 'os';
import * as tls from 'tls';

async function grpcPqcTest(pemBuffer: Buffer, port: number) {
let negotiatedGroupGrpc: string | undefined;
let clientSupportedGroupsGrpc: string | undefined;

const interceptor = (options: any, nextCall: any) => {
return new grpc.InterceptingCall(nextCall(options), {
start: (metadata: any, listener: any, next: any) => {
next(metadata, {
onReceiveMetadata: (receivedMetadata: any, nextListener: any) => {
const group = receivedMetadata.get('x-showcase-tls-group');
if (group && group.length > 0) {
negotiatedGroupGrpc = group[0].toString();
}
const supportedGroups = receivedMetadata.get(
'x-showcase-tls-client-supported-groups'
);
if (supportedGroups && supportedGroups.length > 0) {
clientSupportedGroupsGrpc = supportedGroups[0].toString();
}
nextListener(receivedMetadata);
},
});
},
});
};

const grpcClientOpts = {
grpc,
sslCreds: grpc.credentials.createSsl(pemBuffer),
servicePath: 'localhost',
port: port,
};

const grpcClient = new EchoClient(grpcClientOpts);

const [responseGrpc] = await grpcClient.echo(
{ content: 'grpc-pqc-test' },
{
otherArgs: {
options: {
interceptors: [interceptor],
},
},
}
);

assert.strictEqual(responseGrpc.content, 'grpc-pqc-test');
assert.ok(
negotiatedGroupGrpc,
'Expected negotiated TLS group in gRPC response metadata'
);
assert.strictEqual(negotiatedGroupGrpc, 'X25519MLKEM768');
assert.ok(
clientSupportedGroupsGrpc,
'Expected client supported groups in gRPC response metadata'
);
assert.ok(
clientSupportedGroupsGrpc.includes('X25519MLKEM768'),
'Expected client to include X25519MLKEM768 in supported groups'
);
}

async function httpRestFallbackPqcTest(pemBuffer: Buffer, port: number) {
let negotiatedGroupRest: string | undefined;
let clientSupportedGroupsRest: string | undefined;

const auth = new GoogleAuth({
authClient: new googleAuthLibrary.PassThroughClient(),
});

const originalFetch = auth.fetch.bind(auth);
(auth as any).fetch = async (url: string, opts: any) => {
if (url.startsWith('https:')) {
opts.agent = new https.Agent({
ca: pemBuffer,
keepAlive: true,
});
}
const res = await originalFetch(url, opts);
// res.headers is a Headers instance (with .get()) in standard Fetch API and a plain object in other HTTP clients.
const group =
typeof res.headers.get === 'function'
? res.headers.get('x-showcase-tls-group')
: (res.headers as any)['x-showcase-tls-group'];
if (group) {
negotiatedGroupRest = group;
}
const supportedGroups =
typeof res.headers.get === 'function'
? res.headers.get('x-showcase-tls-client-supported-groups')
: (res.headers as any)['x-showcase-tls-client-supported-groups'];
if (supportedGroups) {
clientSupportedGroupsRest = supportedGroups;
}
return res;
};

const restClientOpts = {
fallback: true,
protocol: 'https',
servicePath: 'localhost',
port: port,
auth: auth,
};

const restClient = new EchoClient(restClientOpts);
const [responseRest] = await restClient.echo({ content: 'rest-pqc-test' });

assert.strictEqual(responseRest.content, 'rest-pqc-test');
assert.ok(
negotiatedGroupRest,
'Expected negotiated TLS group in REST response headers'
);
assert.strictEqual(negotiatedGroupRest, 'X25519MLKEM768');
assert.ok(
clientSupportedGroupsRest,
'Expected client supported groups in REST response headers'
);
assert.ok(
clientSupportedGroupsRest.includes('X25519MLKEM768'),
'Expected client to include X25519MLKEM768 in supported groups'
);
}

/**
* Tests Post Quantum Cryptography (PQC) using the specified CA cert and port.
* It verifies both gRPC and HTTP/REST clients by inspecting the negotiated TLS group.
* @param pemBuffer The CA certificate buffer.
* @param port The port the TLS showcase server is listening on.
*/
async function testPqc(pemBuffer: Buffer, port: number) {
await grpcPqcTest(pemBuffer, port);
await httpRestFallbackPqcTest(pemBuffer, port);
}

/**
* Spins up a TLS-enabled showcase server and runs the PQC compliance tests.
* Cleans up the generated certificate file after the tests complete.
*/
export async function runPqcComplianceTests() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than a test that fails on specific Node versions, would it make sense to skip this test conditionally (i.e. on semver.satisfies(process.version, '>=22.20.0'))?

It could print a "skipping" message.

This way it could be added to our CI more permanently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pushed a change for this

const [major, minor] = process.version.replace('v', '').split('.').map(Number);
if (major < 22 || (major === 22 && minor < 20)) {
console.log(
`skipping PQC compliance tests because node version is ${process.version}`
);
return;
}

const originalShowcaseVersion = process.env['SHOWCASE_VERSION'];
process.env['SHOWCASE_VERSION'] = '0.41.1';
const showcaseServerTls = new ShowcaseServer();
const tlsPort = 7443;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pqc-test-'));
const caCertOutputFile = path.join(tempDir, 'showcase.pem');
const pemPath = caCertOutputFile;

try {
if (fs.existsSync(pemPath)) {
fs.unlinkSync(pemPath);
}

const pemBuffer = await showcaseServerTls.start({
tls: true,
port: `:${tlsPort}`,
caCertOutputFile: caCertOutputFile,
});

if (!pemBuffer) {
throw new Error('Expected showcase server to return CA certificate buffer');
}

await testPqc(pemBuffer, tlsPort);
} finally {
showcaseServerTls.stop();
if (originalShowcaseVersion === undefined) {
delete process.env['SHOWCASE_VERSION'];
} else {
process.env['SHOWCASE_VERSION'] = originalShowcaseVersion;
}
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, {recursive: true, force: true});
}
}
}
Loading