From 69b2c6efe93170a43f836f9882aefb7509d7abfb Mon Sep 17 00:00:00 2001 From: Philip Sanetra Date: Mon, 31 Oct 2022 14:30:40 +0100 Subject: [PATCH] fix: Make CloudEvent data field immutable and enumerable using Object.keys() (#515) Signed-off-by: Philip Sanetra --- .gitignore | 3 ++ examples/express-ex/index.js | 8 +-- src/event/cloudevent.ts | 35 +++++++------ src/event/validation.ts | 21 ++++++++ src/message/mqtt/index.ts | 15 +++--- test/integration/cloud_event_test.ts | 75 ++++++++++++++++++++++++++-- test/integration/mqtt_tests.ts | 10 ++-- test/integration/spec_1_tests.ts | 12 ++--- 8 files changed, 136 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index 90afecec..d6d896dc 100644 --- a/.gitignore +++ b/.gitignore @@ -91,3 +91,6 @@ typings/ # Package lock package-lock.json + +# Jetbrains IDE directories +.idea diff --git a/examples/express-ex/index.js b/examples/express-ex/index.js index 900c7a3a..02c11cc9 100644 --- a/examples/express-ex/index.js +++ b/examples/express-ex/index.js @@ -28,11 +28,11 @@ app.post("/", (req, res) => { const responseEventMessage = new CloudEvent({ source: '/', type: 'event:response', - ...event + ...event, + data: { + hello: 'world' + } }); - responseEventMessage.data = { - hello: 'world' - }; // const message = HTTP.binary(responseEventMessage) const message = HTTP.structured(responseEventMessage) diff --git a/src/event/cloudevent.ts b/src/event/cloudevent.ts index a8e25807..d3cf281d 100644 --- a/src/event/cloudevent.ts +++ b/src/event/cloudevent.ts @@ -9,7 +9,7 @@ import { Emitter } from ".."; import { CloudEventV1 } from "./interfaces"; import { validateCloudEvent } from "./spec"; -import { ValidationError, isBinary, asBase64, isValidType } from "./validation"; +import { ValidationError, isBinary, asBase64, isValidType, base64AsBinary } from "./validation"; /** * An enum representing the CloudEvent specification version @@ -33,7 +33,7 @@ export class CloudEvent implements CloudEventV1 { dataschema?: string; subject?: string; time?: string; - #_data?: T; + data?: T; data_base64?: string; // Extensions should not exist as it's own object, but instead @@ -85,12 +85,21 @@ export class CloudEvent implements CloudEventV1 { delete properties.dataschema; this.data_base64 = properties.data_base64 as string; + + if (this.data_base64) { + this.data = base64AsBinary(this.data_base64) as unknown as T; + } + delete properties.data_base64; this.schemaurl = properties.schemaurl as string; delete properties.schemaurl; - this.data = properties.data; + if (isBinary(properties.data)) { + this.data_base64 = asBase64(properties.data as unknown as Buffer); + } + + this.data = typeof properties.data !== "undefined" ? properties.data : this.data; delete properties.data; // sanity checking @@ -127,17 +136,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`); Object.freeze(this); } - get data(): T | undefined { - return this.#_data; - } - - set data(value: T | undefined) { - if (isBinary(value)) { - this.data_base64 = asBase64(value as unknown as Buffer); - } - this.#_data = value; - } - /** * Used by JSON.stringify(). The name is confusing, but this method is called by * JSON.stringify() when converting this object to JSON. @@ -147,7 +145,11 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`); toJSON(): Record { const event = { ...this }; event.time = new Date(this.time as string).toISOString(); - event.data = this.#_data; + + if (event.data_base64 && event.data) { + delete event.data; + } + return event; } @@ -230,9 +232,6 @@ See: https://github.com/cloudevents/spec/blob/v1.0/spec.md#type-system`); event: CloudEventV1, options: Partial>, strict = true): CloudEvent { - if (event instanceof CloudEvent) { - event = event.toJSON() as CloudEventV1; - } return new CloudEvent(Object.assign({}, event, options), strict); } } diff --git a/src/event/validation.ts b/src/event/validation.ts index 1926b246..c5e81864 100644 --- a/src/event/validation.ts +++ b/src/event/validation.ts @@ -8,6 +8,19 @@ import { ErrorObject } from "ajv"; export type TypeArray = Int8Array | Uint8Array | Int16Array | Uint16Array | Int32Array | Uint32Array | Uint8ClampedArray | Float32Array | Float64Array; +const globalThisPolyfill = (function() { + try { + return globalThis; + } + catch (e) { + try { + return self; + } + catch (e) { + return global; + } + } +}()); /** * An Error class that will be thrown when a CloudEvent @@ -86,6 +99,14 @@ export const asBuffer = (value: string | Buffer | TypeArray): Buffer => throw new TypeError("is not buffer or a valid binary"); })(); +export const base64AsBinary = (base64String: string): Uint8Array => { + const toBinaryString = (base64Str: string): string => globalThisPolyfill.atob + ? globalThisPolyfill.atob(base64Str) + : Buffer.from(base64Str, "base64").toString("binary"); + + return Uint8Array.from(toBinaryString(base64String), (c) => c.charCodeAt(0)); +}; + export const asBase64 = (value: string | Buffer | TypeArray): string => asBuffer(value).toString("base64"); diff --git a/src/message/mqtt/index.ts b/src/message/mqtt/index.ts index 8f11b6bb..3eee93f7 100644 --- a/src/message/mqtt/index.ts +++ b/src/message/mqtt/index.ts @@ -4,6 +4,7 @@ */ import { Binding, Deserializer, CloudEvent, CloudEventV1, CONSTANTS, Message, ValidationError, Headers } from "../.."; +import { base64AsBinary } from "../../event/validation"; export { MQTT, MQTTMessageFactory @@ -50,14 +51,16 @@ const MQTT: Binding = { * @implements {Serializer} */ function binary(event: CloudEventV1): MQTTMessage { - let properties; - if (event instanceof CloudEvent) { - properties = event.toJSON(); - } else { - properties = event; + const properties = { ...event }; + + let body = properties.data as T; + + if (!body && properties.data_base64) { + body = base64AsBinary(properties.data_base64) as unknown as T; } - const body = properties.data as T; + delete properties.data; + delete properties.data_base64; return MQTTMessageFactory(event.datacontenttype as string, properties, body); } diff --git a/test/integration/cloud_event_test.ts b/test/integration/cloud_event_test.ts index f7c95c07..829afff8 100644 --- a/test/integration/cloud_event_test.ts +++ b/test/integration/cloud_event_test.ts @@ -7,24 +7,39 @@ import path from "path"; import fs from "fs"; import { expect } from "chai"; -import { CloudEvent, ValidationError, Version } from "../../src"; +import { CloudEvent, CloudEventV1, ValidationError, Version } from "../../src"; import { asBase64 } from "../../src/event/validation"; const type = "org.cncf.cloudevents.example"; const source = "http://unit.test"; const id = "b46cf653-d48a-4b90-8dfa-355c01061361"; -const fixture = { +const fixture = Object.freeze({ id, specversion: Version.V1, source, type, - data: `"some data"`, -}; + data: `"some data"` +}); const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png"))); const image_base64 = asBase64(imageData); +// Do not replace this with the assignment of a class instance +// as we just want to test if we can enumerate all explicitly defined fields! +const cloudEventV1InterfaceFields: (keyof CloudEventV1)[] = Object.keys({ + id: "", + type: "", + data: undefined, + data_base64: "", + source: "", + time: "", + datacontenttype: "", + dataschema: "", + specversion: "", + subject: "" +} as Required>); + describe("A CloudEvent", () => { it("Can be constructed with a typed Message", () => { const ce = new CloudEvent(fixture); @@ -78,6 +93,58 @@ describe("A CloudEvent", () => { new CloudEvent({ ExtensionWithCaps: "extension value", ...fixture }); }).throw("invalid extension name"); }); + + it("CloudEventV1 interface fields should be enumerable", () => { + const classInstanceKeys = Object.keys(new CloudEvent({ ...fixture })); + + for (const key of cloudEventV1InterfaceFields) { + expect(classInstanceKeys).to.contain(key); + } + }); + + it("throws TypeError on trying to set any field value", () => { + const ce = new CloudEvent({ + ...fixture, + mycustomfield: "initialValue" + }); + + const keySet = new Set([...cloudEventV1InterfaceFields, ...Object.keys(ce)]); + + expect(keySet).not.to.be.empty; + + for (const cloudEventKey of keySet) { + let threw = false; + + try { + ce[cloudEventKey] = "newValue"; + } catch (err) { + threw = true; + expect(err).to.be.instanceOf(TypeError); + expect((err as TypeError).message).to.include("Cannot assign to read only property"); + } + + if (!threw) { + expect.fail(`Assigning a value to ${cloudEventKey} did not throw`); + } + } + }); + + describe("toJSON()", () => { + it("does not return data field if data_base64 field is set to comply with JSON format spec 3.1.1", () => { + const binaryData = new Uint8Array([1,2,3]); + + const ce = new CloudEvent({ + ...fixture, + data: binaryData + }); + + expect(ce.data).to.be.equal(binaryData); + + const json = ce.toJSON(); + expect(json.data).to.not.exist; + expect(json.data_base64).to.be.equal("AQID"); + }); + }); }); describe("A 1.0 CloudEvent", () => { diff --git a/test/integration/mqtt_tests.ts b/test/integration/mqtt_tests.ts index 69b77e52..62d9a8bb 100644 --- a/test/integration/mqtt_tests.ts +++ b/test/integration/mqtt_tests.ts @@ -32,12 +32,12 @@ const ext2Name = "extension2"; const ext2Value = "acme"; // Binary data as base64 -const dataBinary = Uint32Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number); +const dataBinary = Uint8Array.from(JSON.stringify(data), (c) => c.codePointAt(0) as number); const data_base64 = asBase64(dataBinary); // Since the above is a special case (string as binary), let's test // with a real binary file one is likely to encounter in the wild -const imageData = new Uint32Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png"))); +const imageData = new Uint8Array(fs.readFileSync(path.join(process.cwd(), "test", "integration", "ce.png"))); const image_base64 = asBase64(imageData); const PUBLISH = {"Content Type": "application/json; charset=utf-8"}; @@ -281,14 +281,14 @@ describe("MQTT transport", () => { it("Converts base64 encoded data to binary when deserializing structured messages", () => { const message = MQTT.structured(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" })); - const eventDeserialized = MQTT.toEvent(message) as CloudEvent; + const eventDeserialized = MQTT.toEvent(message) as CloudEvent; expect(eventDeserialized.data).to.deep.equal(imageData); expect(eventDeserialized.data_base64).to.equal(image_base64); }); it("Converts base64 encoded data to binary when deserializing binary messages", () => { const message = MQTT.binary(fixture.cloneWith({ data: imageData, datacontenttype: "image/png" })); - const eventDeserialized = MQTT.toEvent(message) as CloudEvent; + const eventDeserialized = MQTT.toEvent(message) as CloudEvent; expect(eventDeserialized.data).to.deep.equal(imageData); expect(eventDeserialized.data_base64).to.equal(image_base64); }); @@ -302,7 +302,7 @@ describe("MQTT transport", () => { it("Does not parse binary data from binary messages with content type application/json", () => { const message = MQTT.binary(fixture.cloneWith({ data: dataBinary })); - const eventDeserialized = MQTT.toEvent(message) as CloudEvent; + const eventDeserialized = MQTT.toEvent(message) as CloudEvent; expect(eventDeserialized.data).to.deep.equal(dataBinary); expect(eventDeserialized.data_base64).to.equal(data_base64); }); diff --git a/test/integration/spec_1_tests.ts b/test/integration/spec_1_tests.ts index 9759d8fc..af899223 100644 --- a/test/integration/spec_1_tests.ts +++ b/test/integration/spec_1_tests.ts @@ -19,7 +19,7 @@ const data = { }; const subject = "subject-x0"; -let cloudevent = new CloudEvent({ +const cloudevent = new CloudEvent({ specversion: Version.V1, id, source, @@ -120,8 +120,8 @@ describe("CloudEvents Spec v1.0", () => { }); it("defaut ID create when an empty string", () => { - cloudevent = cloudevent.cloneWith({ id: "" }); - expect(cloudevent.id.length).to.be.greaterThan(0); + const testEvent = cloudevent.cloneWith({ id: "" }); + expect(testEvent.id.length).to.be.greaterThan(0); }); }); @@ -160,11 +160,11 @@ describe("CloudEvents Spec v1.0", () => { describe("'time'", () => { it("must adhere to the format specified in RFC 3339", () => { const d = new Date(); - cloudevent = cloudevent.cloneWith({ time: d.toString() }, false); + const testEvent = cloudevent.cloneWith({ time: d.toString() }, false); // ensure that we always get back the same thing we passed in - expect(cloudevent.time).to.equal(d.toString()); + expect(testEvent.time).to.equal(d.toString()); // ensure that when stringified, the timestamp is in RFC3339 format - expect(JSON.parse(JSON.stringify(cloudevent)).time).to.equal(new Date(d.toString()).toISOString()); + expect(JSON.parse(JSON.stringify(testEvent)).time).to.equal(new Date(d.toString()).toISOString()); }); }); });