diff --git a/package.json b/package.json index 57395bae..e68c401b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "gulp-tslint": "^8.1.1", "gulp-typescript": "^3.2.1", "gulp-util": "^3.0.8", + "h2-types": "git+https://github.com/kjin/node-h2-types.git", "merge2": "^1.1.0", "mocha": "^3.5.0", "through2": "^2.0.3", @@ -45,5 +46,11 @@ "format": "clang-format -i -style=\"{Language: JavaScript, BasedOnStyle: Google, ColumnLimit: 80}\" src/*.ts test/*.ts", "lint": "tslint -c node_modules/google-ts-style/tslint.json -p . -t codeFrame --type-check", "test": "gulp test" + }, + "dependencies": { + "@types/async": "^2.0.41", + "@types/lodash": "^4.14.73", + "async": "^2.5.0", + "lodash": "^4.17.4" } } diff --git a/src/call-credentials.ts b/src/call-credentials.ts index ef782757..6e196823 100644 --- a/src/call-credentials.ts +++ b/src/call-credentials.ts @@ -1,15 +1,85 @@ -import { Metadata } from './metadata' +import { Metadata } from './metadata'; +import * as async from 'async'; -export class CallCredentials { - static createFromMetadataGenerator(metadataGenerator: (options: Object, cb: (err: Error, metadata: Metadata) => void) => void): CallCredentials { - throw new Error(); +export type CallMetadataGenerator = ( + options: Object, + cb: (err: Error | null, metadata?: Metadata) => void +) => void + +/** + * A class that represents a generic method of adding authentication-related + * metadata on a per-request basis. + */ +export interface CallCredentials { + /** + * Asynchronously generates a new Metadata object. + * @param options Options used in generating the Metadata object. + * @param cb A callback of the form (err, metadata) which will be called with + * either the generated metadata, or an error if one occurred. + */ + generateMetadata: CallMetadataGenerator; + /** + * Creates a new CallCredentials object from properties of both this and + * another CallCredentials object. This object's metadata generator will be + * called first. + * @param callCredentials The other CallCredentials object. + */ + compose: (callCredentials: CallCredentials) => CallCredentials; +} + +export namespace CallCredentials { + /** + * Creates a new CallCredentials object from a given function that generates + * Metadata objects. + * @param metadataGenerator A function that accepts a set of options, and + * generates a Metadata object based on these options, which is passed back + * to the caller via a supplied (err, metadata) callback. + */ + export function createFromMetadataGenerator( + metadataGenerator: CallMetadataGenerator + ): CallCredentials { + return new CallCredentialsImpl([metadataGenerator]); } +} - call(options: Object, cb: (err: Error, metadata: Metadata) => void): void { - throw new Error(); + +class CallCredentialsImpl { + constructor(private metadataGenerators: Array) {} + + generateMetadata( + options: Object, + cb: (err: Error | null, metadata?: Metadata) => void + ): void { + if (this.metadataGenerators.length === 1) { + this.metadataGenerators[0](options, cb); + return; + } + + const tasks: Array> = + this.metadataGenerators.map(fn => fn.bind(null, options)); + const callback: AsyncResultArrayCallback = + (err, metadataArray) => { + if (err || !metadataArray) { + cb(err || new Error('Unknown error')); + return; + } else { + const result: Metadata = new Metadata(); + metadataArray.forEach((metadata) => { + if (metadata) { + result.merge(metadata); + } + }); + cb(null, result); + } + }; + async.parallel(tasks, callback); } compose(callCredentials: CallCredentials): CallCredentials { - throw new Error(); + if (!(callCredentials instanceof CallCredentialsImpl)) { + throw new Error('Unknown CallCredentials implementation provided'); + } + return new CallCredentialsImpl(this.metadataGenerators.concat( + (callCredentials as CallCredentialsImpl).metadataGenerators)); } } diff --git a/src/channel-credentials.ts b/src/channel-credentials.ts index 4998a9e0..975ff310 100644 --- a/src/channel-credentials.ts +++ b/src/channel-credentials.ts @@ -1,29 +1,119 @@ import { CallCredentials } from './call-credentials'; -import { SecureContext } from 'tls'; // or whatever it's actually called +import { createSecureContext, SecureContext } from 'tls'; /** - * A class that contains credentials for communicating over a channel. + * A class that contains credentials for communicating over a channel, as well + * as a set of per-call credentials, which are applied to every method call made + * over a channel initialized with an instance of this class. */ -export class ChannelCredentials { - private constructor() {} +export interface ChannelCredentials { + /** + * Returns a copy of this object with the included set of per-call credentials + * expanded to include callCredentials. + * @param callCredentials A CallCredentials object to associate with this + * instance. + */ + compose(callCredentials: CallCredentials) : ChannelCredentials; - static createSsl(rootCerts?: Buffer, privateKey?: Buffer, certChain?: Buffer) : ChannelCredentials { - throw new Error(); + /** + * Gets the set of per-call credentials associated with this instance. + */ + getCallCredentials() : CallCredentials | null; + + /** + * Gets a SecureContext object generated from input parameters if this + * instance was created with createSsl, or null if this instance was created + * with createInsecure. + */ + getSecureContext() : SecureContext | null; +} + +export namespace ChannelCredentials { + /** + * Return a new ChannelCredentials instance with a given set of credentials. + * The resulting instance can be used to construct a Channel that communicates + * over TLS. + * @param rootCerts The root certificate data. + * @param privateKey The client certificate private key, if available. + * @param certChain The client certificate key chain, if available. + */ + export function createSsl(rootCerts?: Buffer | null, privateKey?: Buffer | null, certChain?: Buffer | null) : ChannelCredentials { + if (privateKey && !certChain) { + throw new Error('Private key must be given with accompanying certificate chain'); + } + if (!privateKey && certChain) { + throw new Error('Certificate chain must be given with accompanying private key'); + } + const secureContext = createSecureContext({ + ca: rootCerts || undefined, + key: privateKey || undefined, + cert: certChain || undefined + }); + return new SecureChannelCredentialsImpl(secureContext); } - static createInsecure() : ChannelCredentials { - throw new Error(); - } - - compose(callCredentials: CallCredentials) : ChannelCredentials { - throw new Error(); - } - - getCallCredentials() : CallCredentials { - throw new Error(); - } - - getSecureContext() : SecureContext { - throw new Error(); + /** + * Return a new ChannelCredentials instance with no credentials. + */ + export function createInsecure() : ChannelCredentials { + return new InsecureChannelCredentialsImpl(); + } +} + + +abstract class ChannelCredentialsImpl implements ChannelCredentials { + protected callCredentials: CallCredentials | null; + + protected constructor(callCredentials?: CallCredentials) { + this.callCredentials = callCredentials || null; + } + + abstract compose(callCredentials: CallCredentials) : ChannelCredentialsImpl; + + getCallCredentials() : CallCredentials | null { + return this.callCredentials; + } + + abstract getSecureContext() : SecureContext | null; +} + +class InsecureChannelCredentialsImpl extends ChannelCredentialsImpl { + constructor(callCredentials?: CallCredentials) { + super(callCredentials); + } + + compose(callCredentials: CallCredentials) : ChannelCredentialsImpl { + const combinedCallCredentials = this.callCredentials ? + this.callCredentials.compose(callCredentials) : + callCredentials; + return new InsecureChannelCredentialsImpl(combinedCallCredentials); + } + + getSecureContext() : SecureContext | null { + return null; + } +} + +class SecureChannelCredentialsImpl extends ChannelCredentialsImpl { + secureContext: SecureContext; + + constructor( + secureContext: SecureContext, + callCredentials?: CallCredentials + ) { + super(callCredentials); + this.secureContext = secureContext; + } + + compose(callCredentials: CallCredentials) : ChannelCredentialsImpl { + const combinedCallCredentials = this.callCredentials ? + this.callCredentials.compose(callCredentials) : + callCredentials; + return new SecureChannelCredentialsImpl(this.secureContext, + combinedCallCredentials); + } + + getSecureContext() : SecureContext | null { + return this.secureContext; } } diff --git a/src/metadata.ts b/src/metadata.ts index 0bb8d8b9..e9e71208 100644 --- a/src/metadata.ts +++ b/src/metadata.ts @@ -1,3 +1,211 @@ -export class Metadata { - +import { forOwn } from 'lodash'; +import * as http2 from 'http2'; + +export type MetadataValue = string | Buffer; + +export interface MetadataObject { + [key: string]: Array; +} + +function cloneMetadataObject(repr: MetadataObject): MetadataObject { + const result: MetadataObject = {}; + forOwn(repr, (value, key) => { + // v.slice copies individual buffer values in value. + // TODO(kjin): Is this necessary + result[key] = value.map(v => { + if (v instanceof Buffer) { + return v.slice(); + } else { + return v; + } + }); + }); + return result; +} + +function isLegalKey(key: string): boolean { + return !!key.match(/^[0-9a-z_.-]+$/); +} + +function isLegalNonBinaryValue(value: string): boolean { + return !!value.match(/^[ -~]+$/); +} + +function isBinaryKey(key: string): boolean { + return key.endsWith('-bin'); +} + +function normalizeKey(key: string): string { + return key.toLowerCase(); +} + +function validate(key: string, value?: MetadataValue): void { + if (!isLegalKey(key)) { + throw new Error('Metadata key"' + key + '" contains illegal characters'); + } + if (value != null) { + if (isBinaryKey(key)) { + if (!(value instanceof Buffer)) { + throw new Error('keys that end with \'-bin\' must have Buffer values'); + } + } else { + if (value instanceof Buffer) { + throw new Error( + 'keys that don\'t end with \'-bin\' must have String values'); + } + if (!isLegalNonBinaryValue(value)) { + throw new Error('Metadata string value "' + value + + '" contains illegal characters'); + } + } + } +} + +/** + * A class for storing metadata. Keys are normalized to lowercase ASCII. + */ +export class Metadata { + constructor(protected readonly internalRepr: MetadataObject = {}) {} + + /** + * Sets the given value for the given key by replacing any other values + * associated with that key. Normalizes the key. + * @param key The key to whose value should be set. + * @param value The value to set. Must be a buffer if and only + * if the normalized key ends with '-bin'. + */ + set(key: string, value: MetadataValue): void { + key = normalizeKey(key); + validate(key, value); + this.internalRepr[key] = [value]; + } + + /** + * Adds the given value for the given key by appending to a list of previous + * values associated with that key. Normalizes the key. + * @param key The key for which a new value should be appended. + * @param value The value to add. Must be a buffer if and only + * if the normalized key ends with '-bin'. + */ + add(key: string, value: MetadataValue): void { + key = normalizeKey(key); + validate(key, value); + if (!this.internalRepr[key]) { + this.internalRepr[key] = [value]; + } else { + this.internalRepr[key].push(value); + } + } + + /** + * Removes the given key and any associated values. Normalizes the key. + * @param key The key whose values should be removed. + */ + remove(key: string): void { + key = normalizeKey(key); + validate(key); + if (Object.prototype.hasOwnProperty.call(this.internalRepr, key)) { + delete this.internalRepr[key]; + } + } + + /** + * Gets a list of all values associated with the key. Normalizes the key. + * @param key The key whose value should be retrieved. + * @return A list of values associated with the given key. + */ + get(key: string): Array { + key = normalizeKey(key); + validate(key); + if (Object.prototype.hasOwnProperty.call(this.internalRepr, key)) { + return this.internalRepr[key]; + } else { + return []; + } + } + + /** + * Gets a plain object mapping each key to the first value associated with it. + * This reflects the most common way that people will want to see metadata. + * @return A key/value mapping of the metadata. + */ + getMap(): { [key: string]: MetadataValue } { + const result: { [key: string]: MetadataValue } = {}; + forOwn(this.internalRepr, (values, key) => { + if(values.length > 0) { + const v = values[0]; + result[key] = v instanceof Buffer ? v.slice() : v; + } + }); + return result; + } + + /** + * Clones the metadata object. + * @return The newly cloned object. + */ + clone(): Metadata { + return new Metadata(cloneMetadataObject(this.internalRepr)); + } + + /** + * Merges all key-value pairs from a given Metadata object into this one. + * If both this object and the given object have values in the same key, + * values from the other Metadata object will be appended to this object's + * values. + * @param other A Metadata object. + */ + merge(other: Metadata): void { + forOwn(other.internalRepr, (values, key) => { + this.internalRepr[key] = (this.internalRepr[key] || []).concat(values); + }); + } + + /** + * Creates an OutgoingHttpHeaders object that can be used with the http2 API. + */ + toHttp2Headers(): http2.OutgoingHttpHeaders { + const result: http2.OutgoingHttpHeaders = {}; + forOwn(this.internalRepr, (values, key) => { + // We assume that the user's interaction with this object is limited to + // through its public API (i.e. keys and values are already validated). + result[key] = values.map((value) => { + if (value instanceof Buffer) { + return value.toString('base64'); + } else { + return value; + } + }); + }); + return result; + } + + /** + * Returns a new Metadata object based fields in a given IncomingHttpHeaders + * object. + * @param headers An IncomingHttpHeaders object. + */ + static fromHttp2Headers(headers: http2.IncomingHttpHeaders): Metadata { + const result = new Metadata(); + forOwn(headers, (values, key) => { + if (isBinaryKey(key)) { + if (Array.isArray(values)) { + values.forEach((value) => { + result.add(key, Buffer.from(value, 'base64')); + }) + } else { + result.add(key, Buffer.from(values, 'base64')); + } + } else { + if (Array.isArray(values)) { + values.forEach((value) => { + result.add(key, value); + }) + } else { + result.add(key, values); + } + } + }); + return result; + } } diff --git a/test/common.ts b/test/common.ts new file mode 100644 index 00000000..fbb6a2a1 --- /dev/null +++ b/test/common.ts @@ -0,0 +1,16 @@ +import * as assert from 'assert'; + +export function mockFunction(): never { + throw new Error('Not implemented'); +} + +export namespace assert2 { + export function noThrowAndReturn(fn: () => T): T { + try { + return fn(); + } catch (e) { + assert.throws(() => { throw e }); + throw e; // for type safety only + } + } +} diff --git a/test/fixtures/README b/test/fixtures/README new file mode 100644 index 00000000..888d95b9 --- /dev/null +++ b/test/fixtures/README @@ -0,0 +1 @@ +CONFIRMEDTESTKEY diff --git a/test/fixtures/ca.pem b/test/fixtures/ca.pem new file mode 100644 index 00000000..6c8511a7 --- /dev/null +++ b/test/fixtures/ca.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla +Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0 +YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT +BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7 ++L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu +g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd +Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau +sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m +oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG +Dfcog5wrJytaQ6UA0wE= +-----END CERTIFICATE----- diff --git a/test/fixtures/server1.key b/test/fixtures/server1.key new file mode 100644 index 00000000..143a5b87 --- /dev/null +++ b/test/fixtures/server1.key @@ -0,0 +1,16 @@ +-----BEGIN PRIVATE KEY----- +MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD +M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf +3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY +AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm +V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY +tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p +dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q +K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR +81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff +DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd +aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2 +ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3 +XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe +F98XJ7tIFfJq +-----END PRIVATE KEY----- diff --git a/test/fixtures/server1.pem b/test/fixtures/server1.pem new file mode 100644 index 00000000..f3d43fcc --- /dev/null +++ b/test/fixtures/server1.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET +MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ +dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx +MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV +BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50 +ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco +LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg +zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd +9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw +CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy +em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G +CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6 +hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh +y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8 +-----END CERTIFICATE----- diff --git a/test/test-call-credentials.ts b/test/test-call-credentials.ts new file mode 100644 index 00000000..13b2302d --- /dev/null +++ b/test/test-call-credentials.ts @@ -0,0 +1,138 @@ +import { Metadata } from '../src/metadata'; +import { CallCredentials, CallMetadataGenerator } from '../src/call-credentials'; +import * as assert from 'assert'; + +// Returns a Promise that resolves to an object containing either an error or +// metadata +function generateMetadata( + callCredentials: CallCredentials, + options: Object +): Promise<{ err?: Error, metadata?: Metadata }> { + return new Promise((resolve) => { + callCredentials.generateMetadata(options, (err, metadata) => { + resolve({ err: err || undefined, metadata: metadata || undefined }); + }); + }); +} + +// Metadata generators + +function makeGenerator(props: Array): CallMetadataGenerator { + return (options: { [propName: string]: string }, cb) => { + const metadata: Metadata = new Metadata(); + props.forEach((prop) => { + if (options[prop]) { + metadata.add(prop, options[prop]); + } + }); + cb(null, metadata); + } +} + +function makeAfterMsElapsedGenerator(ms: number): CallMetadataGenerator { + return (_options, cb) => { + const metadata = new Metadata(); + metadata.add('msElapsed', `${ms}`); + setTimeout(() => cb(null, metadata), ms); + }; +}; + +const generateFromName: CallMetadataGenerator = makeGenerator(['name']); +const generateWithError: CallMetadataGenerator = (_options, cb) => + cb(new Error()); + +// Tests + +describe('CallCredentials', () => { + describe('createFromMetadataGenerator', () => { + it('should accept a metadata generator', () => { + assert.doesNotThrow(() => + CallCredentials.createFromMetadataGenerator(generateFromName)); + }); + }); + + describe('compose', () => { + it('should accept a CallCredentials object and return a new object', () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator(generateFromName); + const callCredentials2 = CallCredentials.createFromMetadataGenerator(generateFromName); + const combinedCredentials = callCredentials1.compose(callCredentials2); + assert.notEqual(combinedCredentials, callCredentials1); + assert.notEqual(combinedCredentials, callCredentials2); + }); + + it('should be chainable', () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator(generateFromName); + const callCredentials2 = CallCredentials.createFromMetadataGenerator(generateFromName); + assert.doesNotThrow(() => { + callCredentials1.compose(callCredentials2) + .compose(callCredentials2) + .compose(callCredentials2); + }); + }); + }); + + describe('generateMetadata', () => { + it('should call the function passed to createFromMetadataGenerator', + async () => { + const callCredentials = CallCredentials.createFromMetadataGenerator(generateFromName); + const { err, metadata } = await generateMetadata(callCredentials, + { name: 'foo' }); + assert.ok(!err); + assert.ok(metadata); + if (metadata) { + assert.deepEqual(metadata.get('name'), ['foo']); + } + } + ); + + it('should emit an error if the associated metadataGenerator does', + async () => { + const callCredentials = CallCredentials.createFromMetadataGenerator( + generateWithError); + const { err, metadata } = await generateMetadata(callCredentials, {}); + assert.ok(err instanceof Error); + assert.ok(!metadata); + } + ); + + it('should combine metadata from multiple generators', async () => { + const [callCreds1, callCreds2, callCreds3, callCreds4] = + [50, 100, 150, 200].map((ms) => { + const generator: CallMetadataGenerator = + makeAfterMsElapsedGenerator(ms); + return CallCredentials.createFromMetadataGenerator(generator); + }); + const testCases = [{ + credentials: callCreds1 + .compose(callCreds2) + .compose(callCreds3) + .compose(callCreds4), + expected: ['50', '100', '150', '200'] + }, { + credentials: callCreds4 + .compose(callCreds3 + .compose(callCreds2 + .compose(callCreds1))), + expected: ['200', '150', '100', '50'] + }, { + credentials: callCreds3 + .compose(callCreds4 + .compose(callCreds1) + .compose(callCreds2)), + expected: ['150', '200', '50', '100'] + } + ]; + const options = {}; + // Try each test case and make sure the msElapsed field is as expected + await Promise.all(testCases.map(async (testCase) => { + const { credentials, expected } = testCase; + const { err, metadata } = await generateMetadata(credentials, options); + assert.ok(!err); + assert.ok(metadata); + if (metadata) { + assert.deepEqual(metadata.get('msElapsed'), expected); + } + })); + }); + }); +}); diff --git a/test/test-channel-credentials.ts b/test/test-channel-credentials.ts new file mode 100644 index 00000000..e2270f9e --- /dev/null +++ b/test/test-channel-credentials.ts @@ -0,0 +1,120 @@ +import * as assert from 'assert'; +import { CallCredentials } from '../src/call-credentials'; +import { ChannelCredentials } from '../src/channel-credentials'; +import { mockFunction, assert2 } from './common'; +import * as fs from 'fs'; +import { promisify } from 'util'; + +class CallCredentialsMock implements CallCredentials { + child: CallCredentialsMock; + constructor(child?: CallCredentialsMock) { + if (child) { + this.child = child; + } + } + + generateMetadata = mockFunction; + + compose(callCredentials: CallCredentialsMock): CallCredentialsMock { + return new CallCredentialsMock(callCredentials); + } + + isEqual(other: CallCredentialsMock): boolean { + if (!this.child) { + return this === other; + } else if (!other || !other.child) { + return false; + } else { + return this.child.isEqual(other.child); + } + } +} + +const readFile: (...args: any[]) => Promise = promisify(fs.readFile); +// A promise which resolves to loaded files in the form { ca, key, cert } +const pFixtures = Promise.all([ + 'ca.pem', + 'server1.key', + 'server1.pem' + ].map((file) => readFile(`test/fixtures/${file}`)) +).then((result) => { + return { + ca: result[0], + key: result[1], + cert: result[2] + }; +}); + +describe('ChannelCredentials Implementation', () => { + describe('createInsecure', () => { + it('should return a ChannelCredentials object with no associated secure context', () => { + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createInsecure()); + assert.ok(!creds.getSecureContext()); + }); + }); + + describe('createSsl', () => { + it('should work when given no arguments', () => { + const creds: ChannelCredentials = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl()); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with just a CA override', async () => { + const { ca } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(ca)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with just a private key and cert chain', async () => { + const { key, cert } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(null, key, cert)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should work with all three parameters specified', async () => { + const { ca, key, cert } = await pFixtures; + const creds = assert2.noThrowAndReturn( + () => ChannelCredentials.createSsl(ca, key, cert)); + assert.ok(!!creds.getSecureContext()); + }); + + it('should throw if just one of private key and cert chain are missing', + async () => { + const { ca, key, cert } = await pFixtures; + assert.throws(() => ChannelCredentials.createSsl(ca, key)); + assert.throws(() => ChannelCredentials.createSsl(ca, key, null)); + assert.throws(() => ChannelCredentials.createSsl(ca, null, cert)); + assert.throws(() => ChannelCredentials.createSsl(null, key)); + assert.throws(() => ChannelCredentials.createSsl(null, key, null)); + assert.throws(() => ChannelCredentials.createSsl(null, null, cert)); + }); + }); + + describe('compose', () => { + it('should return a ChannelCredentials object', () => { + const channelCreds = ChannelCredentials.createInsecure(); + const callCreds = new CallCredentialsMock(); + const composedChannelCreds = channelCreds.compose(callCreds); + assert.ok(!channelCreds.getCallCredentials()); + assert.strictEqual(composedChannelCreds.getCallCredentials(), + callCreds); + }); + + it('should be chainable', () => { + const callCreds1 = new CallCredentialsMock(); + const callCreds2 = new CallCredentialsMock(); + // Associate both call credentials with channelCreds + const composedChannelCreds = ChannelCredentials.createInsecure() + .compose(callCreds1) + .compose(callCreds2); + // Build a mock object that should be an identical copy + const composedCallCreds = callCreds1.compose(callCreds2); + assert.ok(composedCallCreds.isEqual( + composedChannelCreds.getCallCredentials() as CallCredentialsMock)); + }); + }); +}); diff --git a/test/test-metadata.ts b/test/test-metadata.ts new file mode 100644 index 00000000..f884c14d --- /dev/null +++ b/test/test-metadata.ts @@ -0,0 +1,293 @@ +import * as assert from 'assert'; +import * as http2 from 'http2'; +import { range } from 'lodash'; +import * as metadata from '../src/metadata'; + +class Metadata extends metadata.Metadata { + getInternalRepresentation() { + return this.internalRepr; + } + + static fromHttp2Headers(headers: http2.IncomingHttpHeaders): Metadata { + const result = metadata.Metadata.fromHttp2Headers(headers) as Metadata; + result.getInternalRepresentation = + Metadata.prototype.getInternalRepresentation; + return result; + } +} + +const validKeyChars = '0123456789abcdefghijklmnopqrstuvwxyz_-.'; +const validNonBinValueChars = range(0x20, 0x7f) + .map(code => String.fromCharCode(code)) + .join(''); + +describe('Metadata', () => { + let metadata: Metadata; + + beforeEach(() => { + metadata = new Metadata(); + }); + + describe('set', () => { + it('Only accepts string values for non "-bin" keys', () => { + assert.throws(() => { + metadata.set('key', new Buffer('value')); + }); + assert.doesNotThrow(() => { + metadata.set('key', 'value'); + }); + }); + + it('Only accepts Buffer values for "-bin" keys', () => { + assert.throws(() => { + metadata.set('key-bin', 'value'); + }); + assert.doesNotThrow(() => { + metadata.set('key-bin', new Buffer('value')); + }); + }); + + it('Rejects invalid keys', () => { + assert.doesNotThrow(() => { + metadata.set(validKeyChars, 'value'); + }); + assert.throws(() => { + metadata.set('key$', 'value'); + }); + assert.throws(() => { + metadata.set('', 'value'); + }); + }); + + it('Rejects values with non-ASCII characters', () => { + assert.doesNotThrow(() => { + metadata.set('key', validNonBinValueChars); + }); + assert.throws(() => { + metadata.set('key', 'résumé'); + }); + }); + + it('Saves values that can be retrieved', () => { + metadata.set('key', 'value'); + assert.deepEqual(metadata.get('key'), ['value']); + }); + + it('Overwrites previous values', () => { + metadata.set('key', 'value1'); + metadata.set('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value2']); + }); + + it('Normalizes keys', () => { + metadata.set('Key', 'value1'); + assert.deepEqual(metadata.get('key'), ['value1']); + metadata.set('KEY', 'value2'); + assert.deepEqual(metadata.get('key'), ['value2']); + }); + }); + + describe('add', () => { + it('Only accepts string values for non "-bin" keys', () => { + assert.throws(() => { + metadata.add('key', new Buffer('value')); + }); + assert.doesNotThrow(() => { + metadata.add('key', 'value'); + }); + }); + + it('Only accepts Buffer values for "-bin" keys', () => { + assert.throws(() => { + metadata.add('key-bin', 'value'); + }); + assert.doesNotThrow(() => { + metadata.add('key-bin', new Buffer('value')); + }); + }); + + it('Rejects invalid keys', () => { + assert.throws(() => { + metadata.add('key$', 'value'); + }); + assert.throws(() => { + metadata.add('', 'value'); + }); + }); + + it('Saves values that can be retrieved', () => { + metadata.add('key', 'value'); + assert.deepEqual(metadata.get('key'), ['value']); + }); + + it('Combines with previous values', () => { + metadata.add('key', 'value1'); + metadata.add('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + + it('Normalizes keys', () => { + metadata.add('Key', 'value1'); + assert.deepEqual(metadata.get('key'), ['value1']); + metadata.add('KEY', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + }); + + describe('remove', () => { + it('clears values from a key', () => { + metadata.add('key', 'value'); + metadata.remove('key'); + assert.deepEqual(metadata.get('key'), []); + }); + + it('Normalizes keys', () => { + metadata.add('key', 'value'); + metadata.remove('KEY'); + assert.deepEqual(metadata.get('key'), []); + }); + }); + + describe('get', () => { + beforeEach(() => { + metadata.add('key', 'value1'); + metadata.add('key', 'value2'); + metadata.add('key-bin', new Buffer('value')); + }); + + it('gets all values associated with a key', () => { + assert.deepEqual(metadata.get('key'), ['value1', 'value2']); + }); + + it('Normalizes keys', () => { + assert.deepEqual(metadata.get('KEY'), ['value1', 'value2']); + }); + + it('returns an empty list for non-existent keys', () => { + assert.deepEqual(metadata.get('non-existent-key'), []); + }); + + it('returns Buffers for "-bin" keys', () => { + assert.ok(metadata.get('key-bin')[0] instanceof Buffer); + }); + }); + + describe('getMap', () => { + it('gets a map of keys to values', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2'); + metadata.add('KEY3', 'value3a'); + metadata.add('KEY3', 'value3b'); + assert.deepEqual(metadata.getMap(), + {key1: 'value1', + key2: 'value2', + key3: 'value3a'}); + }); + }); + + describe('clone', () => { + it('retains values from the original', () => { + metadata.add('key', 'value'); + const copy = metadata.clone(); + assert.deepEqual(copy.get('key'), ['value']); + }); + + it('Does not see newly added values', () => { + metadata.add('key', 'value1'); + const copy = metadata.clone(); + metadata.add('key', 'value2'); + assert.deepEqual(copy.get('key'), ['value1']); + }); + + it('Does not add new values to the original', () => { + metadata.add('key', 'value1'); + const copy = metadata.clone(); + copy.add('key', 'value2'); + assert.deepEqual(metadata.get('key'), ['value1']); + }); + }); + + describe('merge', () => { + it('appends values from a given metadata object', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2a'); + metadata.add('KEY3', 'value3a'); + metadata.add('key4', 'value4'); + const metadata2 = new Metadata(); + metadata2.add('KEY1', 'value1'); + metadata2.add('key2', 'value2b'); + metadata2.add('key3', 'value3b'); + metadata2.add('key5', 'value5a'); + metadata2.add('key5', 'value5b'); + const metadata2IR = metadata2.getInternalRepresentation(); + metadata.merge(metadata2); + // Ensure metadata2 didn't change + assert.deepEqual(metadata2.getInternalRepresentation(), metadata2IR); + assert.deepEqual(metadata.get('key1'), ['value1', 'value1']); + assert.deepEqual(metadata.get('key2'), ['value2a', 'value2b']); + assert.deepEqual(metadata.get('key3'), ['value3a', 'value3b']); + assert.deepEqual(metadata.get('key4'), ['value4']); + assert.deepEqual(metadata.get('key5'), ['value5a', 'value5b']); + }); + }); + + describe('toHttp2Headers', () => { + it('creates an OutgoingHttpHeaders object with expected values', () => { + metadata.add('key1', 'value1'); + metadata.add('Key2', 'value2'); + metadata.add('KEY3', 'value3a'); + metadata.add('key3', 'value3b'); + metadata.add('key-bin', Buffer.from(range(0, 16))); + metadata.add('key-bin', Buffer.from(range(16, 32))); + metadata.add('key-bin', Buffer.from(range(0, 32))); + const headers = metadata.toHttp2Headers(); + assert.deepEqual(headers, { + key1: ['value1'], + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + 'AAECAwQFBgcICQoLDA0ODw==', + 'EBESExQVFhcYGRobHB0eHw==', + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' + ] + }); + }); + + it('creates an empty header object from empty Metadata', () => { + assert.deepEqual(metadata.toHttp2Headers(), {}); + }); + }); + + describe('fromHttp2Headers', () => { + it('creates a Metadata object with expected values', () => { + const headers = { + key1: 'value1', + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + 'AAECAwQFBgcICQoLDA0ODw==', + 'EBESExQVFhcYGRobHB0eHw==', + 'AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=' + ] + }; + const metadataFromHeaders = Metadata.fromHttp2Headers(headers); + const internalRepr = metadataFromHeaders.getInternalRepresentation(); + assert.deepEqual(internalRepr, { + key1: ['value1'], + key2: ['value2'], + key3: ['value3a', 'value3b'], + 'key-bin': [ + Buffer.from(range(0, 16)), + Buffer.from(range(16, 32)), + Buffer.from(range(0, 32)) + ] + }); + }); + + it('creates an empty Metadata object from empty headers', () => { + const metadataFromHeaders = Metadata.fromHttp2Headers({}); + const internalRepr = metadataFromHeaders.getInternalRepresentation(); + assert.deepEqual(internalRepr, {}); + }); + }); +}); diff --git a/test/test.ts b/test/test.ts deleted file mode 100644 index dbfe3c30..00000000 --- a/test/test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ChannelCredentials } from '../src/channel-credentials'; -import * as assert from 'assert'; - -describe('Channel Credentials', function() { - it('should be an object', function() { - assert.ok(ChannelCredentials); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 4be32f59..e189ea83 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,9 +1,13 @@ { "extends": "./node_modules/google-ts-style/tsconfig-google.json", + "compilerOptions": { + "lib": [ "es6" ], + "typeRoots": [ + "node_modules/h2-types", "node_modules/@types" + ] + }, "include": [ - "src/*.ts", "src/**/*.ts", - "test/*.ts", "test/**/*.ts" ], "exclude": [