diff --git a/src/v1.test.ts b/src/v1.test.ts index d40a70a..0bbe47f 100644 --- a/src/v1.test.ts +++ b/src/v1.test.ts @@ -18,6 +18,7 @@ import { LookupSubjectsResponse, NewClient, ObjectReference, + PermissionsServiceClient, Relationship, RelationshipUpdate, RelationshipUpdate_Operation, @@ -25,8 +26,10 @@ import { WriteRelationshipsRequest, WriteRelationshipsResponse, WriteSchemaRequest, + createStructFromObject, + PbNullValue, } from "./v1.js"; -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; describe("a check with an unknown namespace", () => { it("should raise a failed precondition", () => @@ -681,4 +684,219 @@ describe("Experimental Service", () => { }); }); })); + + describe("WriteRelationships with transaction metadata (Integration Test)", () => { + it("should successfully write relationships with metadata and verify metadata transmission", () => + new Promise((done, fail) => { + const testToken = generateTestToken("v1-int-tx-metadata"); + const client = NewClient( + testToken, + "localhost:50051", + ClientSecurity.INSECURE_LOCALHOST_ALLOWED, + PreconnectServices.SCHEMA_SERVICE | + PreconnectServices.PERMISSIONS_SERVICE, + ); + + const writeSpy = vi.spyOn( + PermissionsServiceClient.prototype, + "writeRelationships", + ); + + const schema = ` + definition test/user {} + definition test/document { + relation viewer: test/user + permission view = viewer + } + `; + const writeSchemaRequest = WriteSchemaRequest.create({ schema }); + + client.writeSchema(writeSchemaRequest, (schemaErr, schemaResponse) => { + if (schemaErr) { + client.close(); + fail(schemaErr); + return; + } + expect(schemaResponse).toBeDefined(); + + const uniqueSuffix = Date.now(); + const resource = ObjectReference.create({ + objectType: "test/document", + objectId: `doc-${uniqueSuffix}`, + }); + + const user = ObjectReference.create({ + objectType: "test/user", + objectId: `user-${uniqueSuffix}`, + }); + + const updates = [ + RelationshipUpdate.create({ + relationship: Relationship.create({ + resource, + relation: "viewer", + subject: SubjectReference.create({ object: user }), + }), + operation: RelationshipUpdate_Operation.CREATE, + }), + ]; + + const metadataObject = { + transaction_id: "test-tx-123", + other_data: "sample", + }; + const transactionMetadata = createStructFromObject(metadataObject); + + const writeRequest = WriteRelationshipsRequest.create({ + updates, + optionalTransactionMetadata: transactionMetadata, + }); + + client.writeRelationships(writeRequest, (err, response) => { + if (err) { + client.close(); + fail(err); + return; + } + + expect(err).toBeNull(); + expect(response).toBeDefined(); + expect(response?.writtenAt).toBeDefined(); + + expect(writeSpy).toHaveBeenCalledTimes(1); + + const actualRequest = writeSpy.mock + .calls[0][0] as WriteRelationshipsRequest; + + expect(actualRequest.updates).toEqual(updates); + + expect(actualRequest.optionalTransactionMetadata).toBeDefined(); + expect(actualRequest.optionalTransactionMetadata).toEqual( + transactionMetadata, + ); + + const transactionIdField = + actualRequest.optionalTransactionMetadata?.fields?.[ + "transaction_id" + ]; + expect(transactionIdField?.kind?.oneofKind).toBe("stringValue"); + if (transactionIdField?.kind?.oneofKind === "stringValue") { + expect(transactionIdField.kind.stringValue).toBe("test-tx-123"); + } + + const otherDataField = + actualRequest.optionalTransactionMetadata?.fields?.["other_data"]; + expect(otherDataField?.kind?.oneofKind).toBe("stringValue"); + if (otherDataField?.kind?.oneofKind === "stringValue") { + expect(otherDataField.kind.stringValue).toBe("sample"); + } + + client.close(); + done(); + }); + }); + })); + }); +}); + +describe("createStructFromObject unit tests", () => { + it("should convert a simple JS object with primitive types", () => { + const obj = { + stringProp: "hello", + numberProp: 123, + booleanProp: true, + }; + const struct = createStructFromObject(obj); + expect(struct.fields.stringProp?.kind.oneofKind).toBe("stringValue"); + expect( + struct.fields.stringProp?.kind.oneofKind === "stringValue" && + struct.fields.stringProp?.kind.stringValue, + ).toBe("hello"); + expect(struct.fields.numberProp?.kind.oneofKind).toBe("numberValue"); + expect( + struct.fields.numberProp?.kind.oneofKind === "numberValue" && + struct.fields.numberProp?.kind.numberValue, + ).toBe(123); + expect(struct.fields.booleanProp?.kind.oneofKind).toBe("boolValue"); + expect( + struct.fields.booleanProp?.kind.oneofKind === "boolValue" && + struct.fields.booleanProp?.kind.boolValue, + ).toBe(true); + }); + + it("should convert a JS object with null values", () => { + const obj = { + nullProp: null, + }; + const struct = createStructFromObject(obj); + expect(struct.fields.nullProp?.kind.oneofKind).toBe("nullValue"); + expect( + struct.fields.nullProp?.kind.oneofKind === "nullValue" && + struct.fields.nullProp?.kind.nullValue, + ).toBe(PbNullValue.NULL_VALUE); + }); + + it("should convert a JS object with nested objects", () => { + const obj = { + nestedProp: { + innerString: "world", + innerNumber: 456, + }, + }; + const struct = createStructFromObject(obj); + const nestedStruct = + struct.fields.nestedProp?.kind.oneofKind === "structValue" && + struct.fields.nestedProp.kind.structValue; + expect(nestedStruct).toBeTruthy(); + if (nestedStruct) { + expect(nestedStruct.fields.innerString?.kind.oneofKind).toBe( + "stringValue", + ); + expect( + nestedStruct.fields.innerString?.kind.oneofKind === "stringValue" && + nestedStruct.fields.innerString?.kind.stringValue, + ).toBe("world"); + expect(nestedStruct.fields.innerNumber?.kind.oneofKind).toBe( + "numberValue", + ); + expect( + nestedStruct.fields.innerNumber?.kind.oneofKind === "numberValue" && + nestedStruct.fields.innerNumber?.kind.numberValue, + ).toBe(456); + } + }); + + it("should convert an empty JS object to an empty Struct", () => { + const obj = {}; + const struct = createStructFromObject(obj); + expect(Object.keys(struct.fields).length).toBe(0); + }); + + it("should throw an error for null input", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => createStructFromObject(null as any)).toThrow( + "Input data for createStructFromObject must be a non-null object.", + ); + }); + + it("should throw an error for non-object input (string)", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => createStructFromObject("not an object" as any)).toThrow( + "Input data for createStructFromObject must be a non-null object.", + ); + }); + + it("should throw an error for non-object input (number)", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => createStructFromObject(123 as any)).toThrow( + "Input data for createStructFromObject must be a non-null object.", + ); + }); + + it("should throw an error for array input", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => createStructFromObject([] as any)).toThrow( + "Input data for createStructFromObject must be a non-null object.", + ); + }); }); diff --git a/src/v1.ts b/src/v1.ts index 376eb70..55bbec7 100644 --- a/src/v1.ts +++ b/src/v1.ts @@ -19,6 +19,13 @@ import { } from "./util.js"; import type { OmitBaseMethods, PromisifiedClient } from "./types.js"; +import { + Struct as ImportedPbStruct, + NullValue as ImportedPbNullValue, +} from "./authzedapi/google/protobuf/struct.js"; +import type { JsonObject } from "@protobuf-ts/runtime"; + +export { ImportedPbStruct as PbStruct, ImportedPbNullValue as PbNullValue }; // A merge of the three generated gRPC clients, with their base methods omitted export type ZedDefaultClientInterface = OmitBaseMethods< @@ -397,6 +404,30 @@ export function NewClientWithChannelCredentials( ): ZedClientInterface { return ZedCombinedClient.create(endpoint, creds, preconnect, options); } +/** + * Creates a google.protobuf.Struct object suitable for use as + * optionalTransactionMetadata in WriteRelationshipsRequest. + * + * @param data A simple JavaScript object (e.g., { key: "value" }) to be converted into a Struct. + * @returns A google.protobuf.Struct object. + */ +export function createStructFromObject(data: JsonObject): ImportedPbStruct { + try { + return ImportedPbStruct.fromJson(data); + } catch (error) { + if ( + error instanceof Error && + error.message.includes( + "Unable to parse message google.protobuf.Struct from JSON", + ) + ) { + throw new Error( + "Input data for createStructFromObject must be a non-null object.", + ); + } + throw error; + } +} export * from "./authzedapi/authzed/api/v1/core.js"; export * from "./authzedapi/authzed/api/v1/experimental_service.js";