From 477923f4c5391eb3df56e4a18d68c1b3505dc23c Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 16 Nov 2021 16:43:38 +1100 Subject: [PATCH 01/93] Begin: implementing BulkRequest and BulkResponse message classes --- src/lib/messages/bulkop.js | 87 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/lib/messages/bulkop.js diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js new file mode 100644 index 0000000..50d78d3 --- /dev/null +++ b/src/lib/messages/bulkop.js @@ -0,0 +1,87 @@ +import Types from "../types.js"; + +/** + * List of valid HTTP methods in a SCIM bulk request operation + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidBulkMethods + * @memberOf SCIMMY.Messages.BulkOp + * @default + */ +const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; + +/** + * SCIM Bulk Request and Response Message Type + * @alias SCIMMY.Messages.BulkOp + * @summary + * * Parses [BulkRequest messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7), making sure "Operations" have been specified, and conform with the SCIM protocol. + * * Provides a method to apply BulkRequest operations and return the results as a BulkResponse. + */ +export class BulkOp { + /** + * SCIM Bulk Request Message Schema ID + * @type {String} + * @private + */ + static #requestId = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; + /** + * SCIM Bulk Response Message Schema ID + * @type {String} + * @private + */ + static #responseId = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"; + + /** + * Number of errors to accept before the operation is terminated and an error response is returned + * @type {Number} + * @private + */ + #errorLimit; + + /** + * Current number of errors encountered when applying operations in a BulkRequest + * @type {Number} + * @private + */ + #errorCount = 0; + + /** + * Operations to perform specified by the BulkRequest + * @type {Object[]} + * @private + */ + #bulkOperations; + + /** + * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest + * @param {Object} request - contents of the BulkRequest operation being performed + * @property {Object[]} Operations - list of SCIM-compliant bulk operations to apply + */ + constructor(request) { + let {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {}; + + // Make sure specified schema is valid + if (schemas.length !== 1 || !schemas.includes(BulkOp.#requestId)) + throw new Types.Error(400, "invalidSyntax", `BulkRequest request body messages must exclusively specify schema as '${BulkOp.#requestId}'`); + + // Make sure failOnErrors is a valid integer + if (typeof failOnErrors !== "number" || !Number.isInteger(failOnErrors) || failOnErrors < 0) + throw new Types.Error(400, "invalidSyntax", `BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer`); + + // Make sure request body contains valid operations to perform + if (!Array.isArray(operations)) + throw new Types.Error(400, "invalidValue", "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"); + if (!operations.length) + throw new Types.Error(400, "invalidValue", "BulkRequest request body must contain 'Operations' attribute with at least one operation"); + + // All seems ok, prepare the BulkResponse + this.schemas = [BulkOp.#responseId]; + this.Operations = []; + + // Store details of BulkRequest to be applied + this.#errorLimit = failOnErrors; + this.#operations = operations; + } +} \ No newline at end of file From 04be7282d908ef8a9062e453d7418404065e1007 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 18 Nov 2021 13:24:28 +1100 Subject: [PATCH 02/93] Tests(SCIMMY.Messages.BulkOp): add initial suite of unit tests --- test/lib/messages/bulkop.js | 97 +++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 test/lib/messages/bulkop.js diff --git a/test/lib/messages/bulkop.js b/test/lib/messages/bulkop.js new file mode 100644 index 0000000..5fdfac9 --- /dev/null +++ b/test/lib/messages/bulkop.js @@ -0,0 +1,97 @@ +import assert from "assert"; + +export let BulkOpSuite = (SCIMMY) => { + const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; + const template = {schemas: [params.id], Operations: [{}, {}]}; + + it("should include static class 'BulkOp'", () => + assert.ok(!!SCIMMY.Messages.BulkOp, "Static class 'BulkOp' not defined")); + + describe("SCIMMY.Messages.BulkOp", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkOp instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkOp instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas, Operations: "a string"}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, + "BulkOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + + it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { + assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas, Operations: []}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkOp instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { + let fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new SCIMMY.Messages.BulkOp({...template, failOnErrors: value}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, + `BulkOp instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); + } + }); + + it("should expect 'maxOperations' argument to be a positive integer, if specified", () => { + let fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new SCIMMY.Messages.BulkOp({...template}, value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, + `BulkOp instantiated with invalid 'maxOperations' parameter ${label}`); + } + }); + + it("should expect number of operations to not exceed 'maxOperations' argument", () => { + assert.throws(() => new SCIMMY.Messages.BulkOp({...template}, 1), + {name: "SCIMError", status: 413, scimType: null, + message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, + "BulkOp instantiated with number of operations exceeding 'maxOperations' parameter"); + }); + + describe("#apply()", () => { + it("should have instance method 'apply'", () => { + assert.ok(typeof (new SCIMMY.Messages.BulkOp({...template})).apply === "function", + "Instance method 'apply' not defined"); + }); + + it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { + await assert.rejects(() => new SCIMMY.Messages.BulkOp({...template, failOnErrors: 1}).apply([{}]), + {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkOp"}, + "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); + }); + + it("should stop processing operations when failOnErrors limit is reached", async () => { + assert.ok((await (new SCIMMY.Messages.BulkOp({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, + "Instance method 'apply' did not stop processing when failOnErrors limit reached"); + }); + }); + }); +} \ No newline at end of file From 003a66a6f06c65ee82db6bc6af9082c45ebbdfc5 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 19 Nov 2021 16:02:45 +1100 Subject: [PATCH 03/93] Add(SCIMMY.Messages.BulkOp): backbone of 'apply' method --- src/lib/messages/bulkop.js | 115 +++++++++++++++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js index 50d78d3..75230ea 100644 --- a/src/lib/messages/bulkop.js +++ b/src/lib/messages/bulkop.js @@ -1,4 +1,6 @@ import Types from "../types.js"; +import Resources from "../resources.js"; +import {Error as ErrorMessage} from "./error.js"; /** * List of valid HTTP methods in a SCIM bulk request operation @@ -55,11 +57,13 @@ export class BulkOp { #bulkOperations; /** - * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest + * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest * @param {Object} request - contents of the BulkRequest operation being performed - * @property {Object[]} Operations - list of SCIM-compliant bulk operations to apply + * @param {Object[]} request.Operations - list of SCIM-compliant bulk operations to apply + * @param {Number} [maxOperations] - maximum number of operations supported in the request + * @property {Object[]} Operations - list of BulkResponse operation results */ - constructor(request) { + constructor(request, maxOperations = 0) { let {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {}; // Make sure specified schema is valid @@ -68,13 +72,18 @@ export class BulkOp { // Make sure failOnErrors is a valid integer if (typeof failOnErrors !== "number" || !Number.isInteger(failOnErrors) || failOnErrors < 0) - throw new Types.Error(400, "invalidSyntax", `BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer`); + throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"); + // Make sure maxOperations is a valid integer + if (typeof maxOperations !== "number" || !Number.isInteger(maxOperations) || maxOperations < 0) + throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'maxOperations' parameter to be a positive integer"); // Make sure request body contains valid operations to perform if (!Array.isArray(operations)) throw new Types.Error(400, "invalidValue", "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"); if (!operations.length) throw new Types.Error(400, "invalidValue", "BulkRequest request body must contain 'Operations' attribute with at least one operation"); + if (maxOperations > 0 && operations.length > maxOperations) + throw new Types.Error(413, null, `Number of operations in BulkRequest exceeds maxOperations limit (${maxOperations})`); // All seems ok, prepare the BulkResponse this.schemas = [BulkOp.#responseId]; @@ -82,6 +91,102 @@ export class BulkOp { // Store details of BulkRequest to be applied this.#errorLimit = failOnErrors; - this.#operations = operations; + this.#bulkOperations = operations; + } + + /** + * Apply the operations specified by the supplied BulkRequest + * @param {SCIMMY.Types.Resource[]|*} [resourceTypes] - resource type classes to be used while processing bulk operations + * @return {SCIMMY.Messages.BulkOp} this BulkOp instance for chaining + */ + async apply(resourceTypes = Object.values(Resources.declared())) { + // Make sure all specified resource types extend the Resource type class so operations can be processed correctly + if (!resourceTypes.every(r => r.prototype instanceof Types.Resource)) + throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkOp"); + + // Set up easy access to resource types by endpoint, and bulkId to real ID map + let typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])), + bulkIds = new Map(); + + for (let op of this.#bulkOperations) { + if (!this.#errorLimit || this.#errorCount < this.#errorLimit) { + let {method, bulkId, path = "", data} = op, + index = this.#bulkOperations.indexOf(op) + 1, + errorSuffix = `in BulkRequest operation #${index}`, + [endpoint, id] = path.substring(1).split("/"), + TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false), + location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path), + result = {method: method, bulkId: bulkId, location: location}, + error = false; + + // Preemptively add the result to the stack + this.Operations.push(result); + + // Make sure method has a value + if (!method && method !== false) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'method' string ${errorSuffix}`)); + // Make sure that value is a string + else if (typeof method !== "string") + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'method' to be a string ${errorSuffix}`)); + // Make sure that string is a valid method + else if (!validMethods.includes(String(method).toUpperCase())) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'method' value '${method}' ${errorSuffix}`)); + // Make sure path has a value + else if (!path && path !== false) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'path' string ${errorSuffix}`)); + // Make sure that path is a string + else if (typeof path !== "string") + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'path' to be a string ${errorSuffix}`)); + // Make sure that string points to a valid resource type + else if (![...typeMap.keys()].some(e => path.startsWith(e))) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'path' value '${path}' ${errorSuffix}`)); + // Make sure there ISN'T a resource targeted if the request type is POST + else if (method.toUpperCase() === "POST" && !!id) + error = new ErrorMessage(new Types.Error(404, null, "POST operation must not target a specific resource")); + // Make sure there IS a resource targeted if the request type isn't POST + else if (method.toUpperCase() !== "POST" && !id) + error = new ErrorMessage(new Types.Error(404, null, `${method.toUpperCase()} operation must target a specific resource`)); + // Make sure data is an object, if method isn't DELETE + else if (method.toUpperCase() !== "DELETE" && (Object(data) !== data || Array.isArray(data))) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value ${errorSuffix}`)) + // If things look OK, attempt to apply the operation + else { + try { + let resource = new TargetResource(id), + value; + + switch (method.toUpperCase()) { + case "POST": + case "PUT": + value = await resource.write(data); + if (bulkId && value.id) bulkIds.set(bulkId, value.id); + Object.assign(result, {status: (!!id ? "200" : "201"), location: value?.meta?.location}); + break; + + case "PATCH": + value = await resource.patch(data); + Object.assign(result, {status: (value ? "200" : "204")}, (value ? {location: value?.meta?.location} : {})); + break; + + case "DELETE": + await resource.dispose(); + Object.assign(result, {status: "204"}); + break; + } + } catch (ex) { + // Set the error variable for final handling + error = new ErrorMessage(ex); + } + } + + // If there was an error, store result and increment error count + if (error instanceof ErrorMessage) { + Object.assign(result, {status: error.status, response: error, location: (String(method).toUpperCase() !== "POST" ? result.location : undefined)}); + this.#errorCount++; + } + } + } + + return this; } } \ No newline at end of file From c67eccd72c3a7327540f6135071b9f47ce051808 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 19 Nov 2021 16:03:45 +1100 Subject: [PATCH 04/93] Add(SCIMMY.Messages): expose BulkOp implementation --- src/lib/messages.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/messages.js b/src/lib/messages.js index 6166228..00384c2 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -1,6 +1,7 @@ import {Error} from "./messages/error.js"; import {ListResponse} from "./messages/listresponse.js"; import {PatchOp} from "./messages/patchop.js"; +import {BulkOp} from "./messages/bulkop.js"; /** * SCIMMY Messages Container Class @@ -13,4 +14,5 @@ export default class Messages { static Error = Error; static ListResponse = ListResponse; static PatchOp = PatchOp; + static BulkOp = BulkOp; } \ No newline at end of file From b6cd3f41c6356ec3a5c09453b6389506b60865f9 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 19 Nov 2021 16:04:41 +1100 Subject: [PATCH 05/93] Tests(SCIMMY.Messages): include BulkOp test suite --- test/lib/messages.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/lib/messages.js b/test/lib/messages.js index b36545c..5ec4b0a 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -2,6 +2,7 @@ import assert from "assert"; import {ErrorSuite} from "./messages/error.js"; import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; +import {BulkOpSuite} from "./messages/bulkop.js"; export let MessagesSuite = (SCIMMY) => { it("should include static class 'Messages'", () => @@ -11,5 +12,6 @@ export let MessagesSuite = (SCIMMY) => { ErrorSuite(SCIMMY); ListResponseSuite(SCIMMY); PatchOpSuite(SCIMMY); + BulkOpSuite(SCIMMY); }); } \ No newline at end of file From 860d1455b8669b07ea8000bbcf06c2526f540797 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 22 Nov 2021 14:27:46 +1100 Subject: [PATCH 06/93] Add(SCIMMY.Messages.BulkOp): handling of bulkId including circular references --- src/lib/messages/bulkop.js | 233 +++++++++++++++++++++++++------------ 1 file changed, 161 insertions(+), 72 deletions(-) diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js index 75230ea..b6253a9 100644 --- a/src/lib/messages/bulkop.js +++ b/src/lib/messages/bulkop.js @@ -56,6 +56,13 @@ export class BulkOp { */ #bulkOperations; + /** + * POST operations that will have their bulkId resolved into a real ID + * @type {Map} + * @private + */ + #bulkIds; + /** * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest * @param {Object} request - contents of the BulkRequest operation being performed @@ -89,9 +96,19 @@ export class BulkOp { this.schemas = [BulkOp.#responseId]; this.Operations = []; + // Get a list of POST ops with bulkIds for direct and circular reference resolution + let postOps = operations.filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string"); + // Store details of BulkRequest to be applied this.#errorLimit = failOnErrors; this.#bulkOperations = operations; + this.#bulkIds = new Map(postOps.map(({bulkId}) => { + // Establish who waits on what, and provide a way for that to happen + let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, + value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})); + + return [bulkId, Object.assign(value, handlers)]; + })); } /** @@ -104,89 +121,161 @@ export class BulkOp { if (!resourceTypes.every(r => r.prototype instanceof Types.Resource)) throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkOp"); - // Set up easy access to resource types by endpoint, and bulkId to real ID map + // Set up easy access to resource types by endpoint, and store pending results let typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])), - bulkIds = new Map(); + bulkIdTransients = [...this.#bulkIds.keys()], + lastErrorIndex = this.#bulkOperations.length + 1, + results = []; - for (let op of this.#bulkOperations) { - if (!this.#errorLimit || this.#errorCount < this.#errorLimit) { - let {method, bulkId, path = "", data} = op, - index = this.#bulkOperations.indexOf(op) + 1, - errorSuffix = `in BulkRequest operation #${index}`, - [endpoint, id] = path.substring(1).split("/"), + for (let op of this.#bulkOperations) results.push((async () => { + // Unwrap useful information from the operation + let {method, bulkId, path = "", data} = op, + // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting + [endpoint, id] = path.substring(1).split("/"), TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false), - location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path), - result = {method: method, bulkId: bulkId, location: location}, - error = false; - - // Preemptively add the result to the stack - this.Operations.push(result); + // Construct a location for the response, and prepare common aspects of the result + location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path), + result = {method: method, bulkId: bulkId, location: location}, + // Find out if this op waits on any other operations + jsonData = (!!data ? JSON.stringify(data) : ""), + waitingOn = (!jsonData.includes("bulkId:") ? [] : [...new Set([...jsonData.matchAll(/"bulkId:(.+?)"/g)].map(([, id]) => id))]), + // Establish error handling + index = this.#bulkOperations.indexOf(op) + 1, + errorSuffix = `in BulkRequest operation #${index}`, + error = false; + + // Ignore the bulkId unless method is POST + bulkId = (String(method).toUpperCase() === "POST" ? bulkId : false); + + // If not the first operation, and there's no circular references, wait on all prior operations + if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => this.#bulkIds.get(bulkId).referencedBy.includes(id)))) { + let lastOp = (await Promise.all(results.slice(0, index - 1))).pop(); - // Make sure method has a value - if (!method && method !== false) - error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'method' string ${errorSuffix}`)); - // Make sure that value is a string - else if (typeof method !== "string") - error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'method' to be a string ${errorSuffix}`)); - // Make sure that string is a valid method - else if (!validMethods.includes(String(method).toUpperCase())) - error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'method' value '${method}' ${errorSuffix}`)); - // Make sure path has a value - else if (!path && path !== false) - error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'path' string ${errorSuffix}`)); - // Make sure that path is a string - else if (typeof path !== "string") - error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'path' to be a string ${errorSuffix}`)); - // Make sure that string points to a valid resource type - else if (![...typeMap.keys()].some(e => path.startsWith(e))) - error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'path' value '${path}' ${errorSuffix}`)); - // Make sure there ISN'T a resource targeted if the request type is POST - else if (method.toUpperCase() === "POST" && !!id) - error = new ErrorMessage(new Types.Error(404, null, "POST operation must not target a specific resource")); - // Make sure there IS a resource targeted if the request type isn't POST - else if (method.toUpperCase() !== "POST" && !id) - error = new ErrorMessage(new Types.Error(404, null, `${method.toUpperCase()} operation must target a specific resource`)); - // Make sure data is an object, if method isn't DELETE - else if (method.toUpperCase() !== "DELETE" && (Object(data) !== data || Array.isArray(data))) - error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value ${errorSuffix}`)) - // If things look OK, attempt to apply the operation - else { - try { - let resource = new TargetResource(id), - value; + // If the last operation failed, and error limit reached, bail out here + if (!lastOp || (lastOp.response instanceof ErrorMessage && !(!this.#errorLimit || (this.#errorCount < this.#errorLimit)))) + return; + } + + // Make sure method has a value + if (!method && method !== false) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'method' string ${errorSuffix}`)); + // Make sure that value is a string + else if (typeof method !== "string") + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'method' to be a string ${errorSuffix}`)); + // Make sure that string is a valid method + else if (!validMethods.includes(String(method).toUpperCase())) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'method' value '${method}' ${errorSuffix}`)); + // Make sure path has a value + else if (!path && path !== false) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Missing or empty 'path' string ${errorSuffix}`)); + // Make sure that path is a string + else if (typeof path !== "string") + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'path' to be a string ${errorSuffix}`)); + // Make sure that string points to a valid resource type + else if (![...typeMap.keys()].includes(`/${endpoint}`)) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `Invalid 'path' value '${path}' ${errorSuffix}`)); + // Make sure there IS a bulkId if the method is POST + else if (method.toUpperCase() === "POST" && !bulkId && bulkId !== false) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string ${errorSuffix}`)); + // Make sure there IS a bulkId if the method is POST + else if (method.toUpperCase() === "POST" && typeof bulkId !== "string") + error = new ErrorMessage(new Types.Error(400, "invalidValue", `POST operation expected 'bulkId' to be a string ${errorSuffix}`)); + // Make sure there ISN'T a resource targeted if the method is POST + else if (method.toUpperCase() === "POST" && !!id) + error = new ErrorMessage(new Types.Error(404, null, `POST operation must not target a specific resource ${errorSuffix}`)); + // Make sure there IS a resource targeted if the method isn't POST + else if (method.toUpperCase() !== "POST" && !id) + error = new ErrorMessage(new Types.Error(404, null, `${method.toUpperCase()} operation must target a specific resource ${errorSuffix}`)); + // Make sure data is an object, if method isn't DELETE + else if (method.toUpperCase() !== "DELETE" && (Object(data) !== data || Array.isArray(data))) + error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value ${errorSuffix}`)) + // Make sure any bulkIds referenced in data can eventually be resolved + else if (!waitingOn.every((id) => this.#bulkIds.has(id))) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `No POST operation found matching bulkId '${waitingOn.find((id) => !this.#bulkIds.has(id))}'`)); + // If things look OK, attempt to apply the operation + else { + try { + // Go through and wait on any referenced POST bulkIds + for (let referenceId of waitingOn) { + // Find the referenced operation to wait for + let reference = this.#bulkIds.get(referenceId), + referenceIndex = bulkIdTransients.indexOf(referenceId); + + // If the reference is also waiting on us, we have ourselves a circular reference! + if (bulkId && !id && reference.referencedBy.includes(bulkId) && (bulkIdTransients.indexOf(bulkId) < referenceIndex)) { + // Attempt to POST self without reference so referenced operation can complete and give us its ID! + ({id} = await new TargetResource().write(Object.entries(data) + // Remove any values that reference a bulkId + .filter(([,v]) => !JSON.stringify(v).includes("bulkId:")) + .reduce((res, [k, v]) => (((res[k] = v) || true) && res), {}))); + + // Set the ID for future use and resolve pending references + jsonData = JSON.stringify(Object.assign(data, {id: id})); + this.#bulkIds.get(bulkId).resolve(id); + } - switch (method.toUpperCase()) { - case "POST": - case "PUT": - value = await resource.write(data); - if (bulkId && value.id) bulkIds.set(bulkId, value.id); - Object.assign(result, {status: (!!id ? "200" : "201"), location: value?.meta?.location}); - break; - - case "PATCH": - value = await resource.patch(data); - Object.assign(result, {status: (value ? "200" : "204")}, (value ? {location: value?.meta?.location} : {})); - break; - - case "DELETE": - await resource.dispose(); - Object.assign(result, {status: "204"}); - break; + try { + // Replace reference with real value once resolved + jsonData = jsonData.replaceAll(`bulkId:${referenceId}`, await reference); + data = JSON.parse(jsonData); + } catch (ex) { + // Referenced POST operation precondition failed, remove any created resource and bail out + if (bulkId && id) await new TargetResource(id).dispose(); + + // If we're following on from a prior failure, no need to explain why, otherwise, explain the failure + if (ex instanceof ErrorMessage && (!!this.#errorLimit && this.#errorCount >= this.#errorLimit && index > lastErrorIndex)) return; + else throw new Types.Error(412, null, `Referenced POST operation with bulkId '${referenceId}' was not successful`); } - } catch (ex) { - // Set the error variable for final handling - error = new ErrorMessage(ex); } + + // Get ready + let resource = new TargetResource(id), + value; + + // Do the thing! + switch (method.toUpperCase()) { + case "POST": + case "PUT": + value = await resource.write(data); + if (bulkId && !resource.id && value.id) this.#bulkIds.get(bulkId).resolve(value.id); + Object.assign(result, {status: (!bulkId ? "200" : "201"), location: value?.meta?.location}); + break; + + case "PATCH": + value = await resource.patch(data); + Object.assign(result, {status: (value ? "200" : "204")}, (value ? {location: value?.meta?.location} : {})); + break; + + case "DELETE": + await resource.dispose(); + Object.assign(result, {status: "204"}); + break; + } + } catch (ex) { + // Coerce the exception into a SCIMError + if (!(ex instanceof Types.Error)) + ex = new Types.Error(...(ex instanceof TypeError ? [400, "invalidValue"] : [500, null]), ex.message); + + // Set the error variable for final handling, and reject any pending operations + error = new ErrorMessage(ex); } + } + + // If there was an error, store result and increment error count + if (error instanceof ErrorMessage) { + Object.assign(result, {status: error.status, response: error, location: (String(method).toUpperCase() !== "POST" ? result.location : undefined)}); + lastErrorIndex = (index < lastErrorIndex ? index : lastErrorIndex); + this.#errorCount++; - // If there was an error, store result and increment error count - if (error instanceof ErrorMessage) { - Object.assign(result, {status: error.status, response: error, location: (String(method).toUpperCase() !== "POST" ? result.location : undefined)}); - this.#errorCount++; - } + // Also reject the pending bulkId promise as no resource ID can exist + if (bulkId) this.#bulkIds.get(bulkId).reject(error); } - } + + return result; + })()); + // Store the results and return the BulkOp for chaining + this.Operations.push(...(await Promise.all(results)).filter(r => r)); return this; } } \ No newline at end of file From 4bb232a073092d0e98213cbd3977a69480e867d9 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 22 Nov 2021 17:29:32 +1100 Subject: [PATCH 07/93] Tests(SCIMMY.Messages.BulkOp): add fixtures for apply method --- test/lib/messages/bulkop.js | 235 ++++++++++++++++++++++++++++++++++ test/lib/messages/bulkop.json | 74 +++++++++++ 2 files changed, 309 insertions(+) create mode 100644 test/lib/messages/bulkop.json diff --git a/test/lib/messages/bulkop.js b/test/lib/messages/bulkop.js index 5fdfac9..30f57aa 100644 --- a/test/lib/messages/bulkop.js +++ b/test/lib/messages/bulkop.js @@ -1,9 +1,50 @@ +import {promises as fs} from "fs"; +import path from "path"; +import url from "url"; import assert from "assert"; export let BulkOpSuite = (SCIMMY) => { + const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); + const fixtures = fs.readFile(path.join(basepath, "./bulkop.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; const template = {schemas: [params.id], Operations: [{}, {}]}; + /** + * BulkOp Test Resource Class + * Because BulkOp needs a set of implemented resources to test against + */ + class Test extends SCIMMY.Types.Resource { + // Store some helpful things for the mock methods + static #lastId = 0; + static #instances = []; + static reset() { + Test.#lastId = 0; + Test.#instances = []; + return Test; + } + + // Endpoint/basepath required by all Resource implementations + static endpoint = "/Test"; + static basepath() {} + + // Mock write method that assigns IDs and stores in static instances array + async write(instance) { + // Give the instance an ID and assign data to it + let target = Object.assign((!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), + JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined}))); + + // Save the instance if necessary and return it + if (!Test.#instances.includes(target)) Test.#instances.push(target); + return {...target, meta: {location: `/Test/${target.id}`}}; + } + + // Mock dispose method that removes from static instances array + async dispose() { + if (this.id) Test.#instances.splice(Test.#instances.indexOf(Test.#instances.find(i => i.id === this.id)), 1); + else throw new SCIMMY.Types.Error(404, null, "DELETE operation must target a specific resource"); + } + } + it("should include static class 'BulkOp'", () => assert.ok(!!SCIMMY.Messages.BulkOp, "Static class 'BulkOp' not defined")); @@ -88,9 +129,203 @@ export let BulkOpSuite = (SCIMMY) => { "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); }); + it("should expect 'method' attribute to have a value for each operation", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations, + expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) + }})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'method' attribute to be present for each operation"); + }); + + it("should expect 'method' attribute to be a string for each operation", async () => { + let fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: value}]})).apply())?.Operations, + expected = [{status: "400", method: value, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + `Instance method 'apply' did not reject 'method' attribute ${label}`); + } + }); + + it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "a string"}]})).apply())?.Operations, + expected = [{status: "400", method: "a string", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not reject invalid 'method' string value 'a string'"); + }); + + it("should expect 'path' attribute to have a value for each operation", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations, + expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) + }})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to be present for each operation"); + }); + + it("should expect 'path' attribute to be a string for each operation", async () => { + let fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations, + expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + `Instance method 'apply' did not reject 'path' attribute ${label}`); + } + }); + + it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations, + expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to refer to a valid resource type endpoint"); + }); + + it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations, + expected = [{status: "404", method: "POST", bulkId: "asdf", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute not to specify a resource ID when 'method' was POST"); + }); + + it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations, + expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) + }})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to specify a resource ID when 'method' was not POST"); + }); + + it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations, + expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) + }})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'bulkId' attribute to be present for each 'POST' operation"); + }); + + it("should expect 'bulkId' attribute to be a string for each 'POST' operation", async () => { + let fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations, + expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + `Instance method 'apply' did not reject 'path' attribute ${label}`); + } + }); + + it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations, + expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) + }})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'data' attribute to be present when 'method' was not DELETE"); + }); + + it("should expect 'data' attribute to be a single complex value when 'method' is not DELETE", async () => { + let suite = [ + {method: "POST", path: "/Test", bulkId: "asdf"}, + {method: "PUT", path: "/Test/1"}, + {method: "PATCH", path: "/Test/1"} + ], + fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1] + ]; + + for (let op of suite) { + for (let [label, value] of fixtures) { + let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations, + expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) + }}]; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + `Instance method 'apply' did not reject 'data' attribute ${label}`); + } + } + }); + it("should stop processing operations when failOnErrors limit is reached", async () => { + let {inbound: {failOnErrors: suite}} = await fixtures; + assert.ok((await (new SCIMMY.Messages.BulkOp({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, "Instance method 'apply' did not stop processing when failOnErrors limit reached"); + + for (let fixture of suite) { + let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not stop processing in inbound failOnErrors fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should resolve bulkId references that are out of order", async () => { + let {inbound: {bulkId: {unordered: suite}}} = await fixtures; + + for (let fixture of suite) { + let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not resolve references in inbound bulkId unordered fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should resolve bulkId references that are circular", async () => { + let {inbound: {bulkId: {circular: suite}}} = await fixtures; + + for (let fixture of suite) { + let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); + } }); }); }); diff --git a/test/lib/messages/bulkop.json b/test/lib/messages/bulkop.json new file mode 100644 index 0000000..d1e5e25 --- /dev/null +++ b/test/lib/messages/bulkop.json @@ -0,0 +1,74 @@ +{ + "inbound": { + "failOnErrors": [ + { + "source": {"failOnErrors": 1, "Operations": [ + {"method": "DELETE", "path": "/Test"}, {"method": "DELETE", "path": "/Test"} + ]}, + "target": ["404"] + }, + { + "source": {"failOnErrors": 1, "Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "asdf", "data": {}}, + {"method": "DELETE", "path": "/Test"}, {"method": "DELETE", "path": "/Test/1"} + ]}, + "target": ["201", "404"] + }, + { + "source": {"failOnErrors": 2, "Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "asdf", "data": {}}, + {"method": "DELETE", "path": "/Test"}, {"method": "DELETE", "path": "/Test/1"} + ]}, + "target": ["201", "404", "204"] + }, + { + "source": {"failOnErrors": 2, "Operations": [ + {"method": "POST", "path": "/Test"}, {"method": "DELETE", "path": "/Test"}, {"method": "DELETE", "path": "/Test/1"} + ]}, + "target": ["400", "404"] + }, + { + "source": {"failOnErrors": 2, "Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "asdf", "data": {}}, {"method": "DELETE", "path": "/Test"}, + {"method": "POST", "path": "/Test"}, {"method": "DELETE", "path": "/Test/1"} + ]}, + "target": ["201", "404", "400"] + }, + { + "source": {"failOnErrors": 3, "Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "asdf", "data": {}}, {"method": "DELETE", "path": "/Test"}, + {"method": "POST", "path": "/Test"}, {"method": "DELETE", "path": "/Test/1"} + ]}, + "target": ["201", "404", "400", "204"] + } + ], + "bulkId": { + "unordered": [ + { + "source": {"Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "ytrewq", "data": {"displayName": "Group B", "members": [{"value": "bulkId:qwerty"}]}}, + {"method": "POST", "path": "/Test", "bulkId": "qwerty", "data": {"displayName": "Group A", "members": [{"value": "bulkId:ytrewq"}]}} + ]}, + "target": ["201", "201"] + } + ], + "circular": [ + { + "source": {"Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "qwerty", "data": {"displayName": "Group A", "members": [{"value": "bulkId:asdfgh"}]}}, + {"method": "POST", "path": "/Test", "bulkId": "asdfgh", "data": {"displayName": "Group B", "members": [{"value": "bulkId:qwerty"}]}} + ]}, + "target": ["201", "201"] + }, + { + "source": {"Operations": [ + {"method": "POST", "path": "/Test", "bulkId": "ytrewq", "data": {"displayName": "Group C", "members": [{"value": "bulkId:asdfgh"}]}}, + {"method": "POST", "path": "/Test", "bulkId": "asdfgh", "data": {"displayName": "Group B", "members": [{"value": "bulkId:qwerty"}]}}, + {"method": "POST", "path": "/Test", "bulkId": "qwerty", "data": {"displayName": "Group A", "members": [{"value": "bulkId:ytrewq"}, {"value": "bulkId:asdfgh"}]}} + ]}, + "target": ["201", "201", "201"] + } + ] + } + } +} \ No newline at end of file From 18190c64cb69d99f3130a6632b0df9a3f6df5105 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 22 Nov 2021 17:30:51 +1100 Subject: [PATCH 08/93] Fix(SCIMMY.Messages.BulkOp): path and data parameter validation anomalies --- src/lib/messages/bulkop.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js index b6253a9..c2ea4a4 100644 --- a/src/lib/messages/bulkop.js +++ b/src/lib/messages/bulkop.js @@ -104,7 +104,7 @@ export class BulkOp { this.#bulkOperations = operations; this.#bulkIds = new Map(postOps.map(({bulkId}) => { // Establish who waits on what, and provide a way for that to happen - let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, + let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})); return [bulkId, Object.assign(value, handlers)]; @@ -131,11 +131,11 @@ export class BulkOp { // Unwrap useful information from the operation let {method, bulkId, path = "", data} = op, // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting - [endpoint, id] = path.substring(1).split("/"), - TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false), + [endpoint, id] = (typeof path === "string" ? path : "").substring(1).split("/"), + TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false), // Construct a location for the response, and prepare common aspects of the result - location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path), - result = {method: method, bulkId: bulkId, location: location}, + location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path || undefined), + result = {method: method, bulkId: (typeof bulkId === "string" ? bulkId : undefined), location: (typeof location === "string" ? location : undefined)}, // Find out if this op waits on any other operations jsonData = (!!data ? JSON.stringify(data) : ""), waitingOn = (!jsonData.includes("bulkId:") ? [] : [...new Set([...jsonData.matchAll(/"bulkId:(.+?)"/g)].map(([, id]) => id))]), @@ -145,7 +145,7 @@ export class BulkOp { error = false; // Ignore the bulkId unless method is POST - bulkId = (String(method).toUpperCase() === "POST" ? bulkId : false); + bulkId = (String(method).toUpperCase() === "POST" ? bulkId : undefined); // If not the first operation, and there's no circular references, wait on all prior operations if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => this.#bulkIds.get(bulkId).referencedBy.includes(id)))) { @@ -268,7 +268,7 @@ export class BulkOp { this.#errorCount++; // Also reject the pending bulkId promise as no resource ID can exist - if (bulkId) this.#bulkIds.get(bulkId).reject(error); + if (bulkId && this.#bulkIds.has(bulkId)) this.#bulkIds.get(bulkId).reject(error); } return result; From 526d0afb3cbdcab6bf7996dd14748a1dfc2a570d Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 22 Nov 2021 17:41:00 +1100 Subject: [PATCH 09/93] Document(SCIMMY.Messages.BulkOp): add failOnErrors parameter JSDoc tag --- src/lib/messages/bulkop.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js index c2ea4a4..77f4489 100644 --- a/src/lib/messages/bulkop.js +++ b/src/lib/messages/bulkop.js @@ -17,6 +17,7 @@ const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; /** * SCIM Bulk Request and Response Message Type * @alias SCIMMY.Messages.BulkOp + * @since 1.0.0 * @summary * * Parses [BulkRequest messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7), making sure "Operations" have been specified, and conform with the SCIM protocol. * * Provides a method to apply BulkRequest operations and return the results as a BulkResponse. @@ -67,6 +68,7 @@ export class BulkOp { * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest * @param {Object} request - contents of the BulkRequest operation being performed * @param {Object[]} request.Operations - list of SCIM-compliant bulk operations to apply + * @param {Number} [request.failOnErrors] - number of error results to encounter before aborting any following operations * @param {Number} [maxOperations] - maximum number of operations supported in the request * @property {Object[]} Operations - list of BulkResponse operation results */ From 6bea2af52134a76186ea0fb4c1b3c1a828d4eeb4 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 13:46:17 +1100 Subject: [PATCH 10/93] Add(SCIMMY.Messages.BulkResponse): SCIM bulk response message class --- src/lib/messages.js | 6 ++++-- src/lib/messages/bulkresponse.js | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 src/lib/messages/bulkresponse.js diff --git a/src/lib/messages.js b/src/lib/messages.js index 00384c2..62b3d72 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -1,7 +1,8 @@ import {Error} from "./messages/error.js"; import {ListResponse} from "./messages/listresponse.js"; import {PatchOp} from "./messages/patchop.js"; -import {BulkOp} from "./messages/bulkop.js"; +import {BulkRequest} from "./messages/bulkop.js"; +import {BulkResponse} from "./messages/bulkresponse.js"; /** * SCIMMY Messages Container Class @@ -14,5 +15,6 @@ export default class Messages { static Error = Error; static ListResponse = ListResponse; static PatchOp = PatchOp; - static BulkOp = BulkOp; + static BulkRequest = BulkRequest; + static BulkResponse = BulkResponse; } \ No newline at end of file diff --git a/src/lib/messages/bulkresponse.js b/src/lib/messages/bulkresponse.js new file mode 100644 index 0000000..1fa24e8 --- /dev/null +++ b/src/lib/messages/bulkresponse.js @@ -0,0 +1,37 @@ +/** + * SCIM Bulk Response Message Type + * @alias SCIMMY.Messages.BulkResponse + * @since 1.0.0 + * @summary + * * Encapsulates bulk operation results as [BulkResponse messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7) for consumption by a client. + * * Provides a method to unwrap BulkResponse results into operation success status, and map newly created resource IDs to their BulkRequest bulkIds. + */ +export class BulkResponse { + /** + * SCIM BulkResponse Message Schema ID + * @type {String} + * @private + */ + static #id = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"; + + /** + * Instantiate a new SCIM BulkResponse message from the supplied Operations + * @param {Object|Object[]} request - contents of the BulkResponse if object, or results of performed operations if array + * @param {Object[]} [request.Operations] - list of applied SCIM-compliant bulk operation results, if request is an object + * @property {Object[]} Operations - list of BulkResponse operation results + */ + constructor(request) { + let outbound = Array.isArray(request), + operations = (outbound ? request : request?.Operations ?? []); + + // Verify the BulkResponse contents are valid + if (!outbound && Array.isArray(request.schemas) && (!request.schemas.includes(BulkResponse.#id) || request.schemas.length > 1)) + throw new TypeError(`BulkResponse request body messages must exclusively specify schema as '${BulkResponse.#id}'`); + if (!Array.isArray(operations)) + throw new TypeError("Expected 'Operations' property of 'request' parameter to be an array in BulkResponse constructor"); + + // All seems ok, prepare the BulkResponse + this.schemas = [BulkResponse.#id]; + this.Operations = [...operations]; + } +} \ No newline at end of file From ab9e5242ed725c7d4ec38fd10e389dd2d0e689d6 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 13:48:50 +1100 Subject: [PATCH 11/93] Refactor(SCIMMY.Messages.BulkOp): begin renaming BulkOp to BulkRequest --- src/lib/messages/bulkop.js | 136 ++++++++---------- test/lib/messages.js | 4 +- test/lib/messages/bulkop.js | 84 +++++------ .../{bulkop.json => bulkrequest.json} | 0 4 files changed, 100 insertions(+), 124 deletions(-) rename test/lib/messages/{bulkop.json => bulkrequest.json} (100%) diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkop.js index 77f4489..4520196 100644 --- a/src/lib/messages/bulkop.js +++ b/src/lib/messages/bulkop.js @@ -1,6 +1,7 @@ +import {Error as ErrorMessage} from "./error.js"; +import {BulkResponse} from "./bulkresponse.js"; import Types from "../types.js"; import Resources from "../resources.js"; -import {Error as ErrorMessage} from "./error.js"; /** * List of valid HTTP methods in a SCIM bulk request operation @@ -9,60 +10,33 @@ import {Error as ErrorMessage} from "./error.js"; * @constant * @type {String[]} * @alias ValidBulkMethods - * @memberOf SCIMMY.Messages.BulkOp + * @memberOf SCIMMY.Messages.BulkRequest * @default */ const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; /** - * SCIM Bulk Request and Response Message Type - * @alias SCIMMY.Messages.BulkOp + * SCIM Bulk Request Message Type + * @alias SCIMMY.Messages.BulkRequest * @since 1.0.0 * @summary * * Parses [BulkRequest messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.7), making sure "Operations" have been specified, and conform with the SCIM protocol. * * Provides a method to apply BulkRequest operations and return the results as a BulkResponse. */ -export class BulkOp { - /** - * SCIM Bulk Request Message Schema ID - * @type {String} - * @private - */ - static #requestId = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; +export class BulkRequest { /** - * SCIM Bulk Response Message Schema ID + * SCIM BulkRequest Message Schema ID * @type {String} * @private */ - static #responseId = "urn:ietf:params:scim:api:messages:2.0:BulkResponse"; - - /** - * Number of errors to accept before the operation is terminated and an error response is returned - * @type {Number} - * @private - */ - #errorLimit; - - /** - * Current number of errors encountered when applying operations in a BulkRequest - * @type {Number} - * @private - */ - #errorCount = 0; - - /** - * Operations to perform specified by the BulkRequest - * @type {Object[]} - * @private - */ - #bulkOperations; + static #id = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; /** - * POST operations that will have their bulkId resolved into a real ID - * @type {Map} + * Whether or not the incoming BulkRequest has been applied + * @type {Boolean} * @private */ - #bulkIds; + #dispatched = false; /** * Instantiate a new SCIM BulkResponse message from the supplied BulkRequest @@ -76,16 +50,14 @@ export class BulkOp { let {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {}; // Make sure specified schema is valid - if (schemas.length !== 1 || !schemas.includes(BulkOp.#requestId)) - throw new Types.Error(400, "invalidSyntax", `BulkRequest request body messages must exclusively specify schema as '${BulkOp.#requestId}'`); - + if (schemas.length !== 1 || !schemas.includes(BulkRequest.#id)) + throw new Types.Error(400, "invalidSyntax", `BulkRequest request body messages must exclusively specify schema as '${BulkRequest.#id}'`); // Make sure failOnErrors is a valid integer if (typeof failOnErrors !== "number" || !Number.isInteger(failOnErrors) || failOnErrors < 0) throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"); // Make sure maxOperations is a valid integer if (typeof maxOperations !== "number" || !Number.isInteger(maxOperations) || maxOperations < 0) throw new Types.Error(400, "invalidSyntax", "BulkRequest expected 'maxOperations' parameter to be a positive integer"); - // Make sure request body contains valid operations to perform if (!Array.isArray(operations)) throw new Types.Error(400, "invalidValue", "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"); @@ -94,42 +66,47 @@ export class BulkOp { if (maxOperations > 0 && operations.length > maxOperations) throw new Types.Error(413, null, `Number of operations in BulkRequest exceeds maxOperations limit (${maxOperations})`); - // All seems ok, prepare the BulkResponse - this.schemas = [BulkOp.#responseId]; - this.Operations = []; - - // Get a list of POST ops with bulkIds for direct and circular reference resolution - let postOps = operations.filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string"); - - // Store details of BulkRequest to be applied - this.#errorLimit = failOnErrors; - this.#bulkOperations = operations; - this.#bulkIds = new Map(postOps.map(({bulkId}) => { - // Establish who waits on what, and provide a way for that to happen - let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, - value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})); - - return [bulkId, Object.assign(value, handlers)]; - })); + // All seems ok, prepare the BulkRequest body + this.schemas = [BulkRequest.#id]; + this.Operations = [...operations]; + if (failOnErrors) this.failOnErrors = failOnErrors; } /** * Apply the operations specified by the supplied BulkRequest * @param {SCIMMY.Types.Resource[]|*} [resourceTypes] - resource type classes to be used while processing bulk operations - * @return {SCIMMY.Messages.BulkOp} this BulkOp instance for chaining + * @returns {SCIMMY.Messages.BulkResponse} a new BulkResponse Message instance with results of the requested operations */ async apply(resourceTypes = Object.values(Resources.declared())) { + // Bail out if BulkRequest message has already been applied + if (this.#dispatched) + throw new TypeError("BulkRequest 'apply' method must not be called more than once"); // Make sure all specified resource types extend the Resource type class so operations can be processed correctly - if (!resourceTypes.every(r => r.prototype instanceof Types.Resource)) - throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkOp"); + else if (!resourceTypes.every(r => r.prototype instanceof Types.Resource)) + throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkRequest"); + // Seems OK, mark the BulkRequest as dispatched so apply can't be called again + else this.#dispatched = true; // Set up easy access to resource types by endpoint, and store pending results let typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])), - bulkIdTransients = [...this.#bulkIds.keys()], - lastErrorIndex = this.#bulkOperations.length + 1, - results = []; + results = [], + // Get a list of POST ops with bulkIds for direct and circular reference resolution + bulkIds = new Map(this.Operations + .filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string") + .map(({bulkId}, index, postOps) => { + // Establish who waits on what, and provide a way for that to happen + let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, + value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})); + + return [bulkId, Object.assign(value, handlers)]; + }) + ), + bulkIdTransients = [...bulkIds.keys()], + // Establish error handling for the entire list of operations + errorCount = 0, errorLimit = this.failOnErrors, + lastErrorIndex = this.Operations.length + 1; - for (let op of this.#bulkOperations) results.push((async () => { + for (let op of this.Operations) results.push((async () => { // Unwrap useful information from the operation let {method, bulkId, path = "", data} = op, // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting @@ -141,8 +118,8 @@ export class BulkOp { // Find out if this op waits on any other operations jsonData = (!!data ? JSON.stringify(data) : ""), waitingOn = (!jsonData.includes("bulkId:") ? [] : [...new Set([...jsonData.matchAll(/"bulkId:(.+?)"/g)].map(([, id]) => id))]), - // Establish error handling - index = this.#bulkOperations.indexOf(op) + 1, + // Establish error handling for this operation + index = this.Operations.indexOf(op) + 1, errorSuffix = `in BulkRequest operation #${index}`, error = false; @@ -150,11 +127,11 @@ export class BulkOp { bulkId = (String(method).toUpperCase() === "POST" ? bulkId : undefined); // If not the first operation, and there's no circular references, wait on all prior operations - if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => this.#bulkIds.get(bulkId).referencedBy.includes(id)))) { + if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => bulkIds.get(bulkId).referencedBy.includes(id)))) { let lastOp = (await Promise.all(results.slice(0, index - 1))).pop(); // If the last operation failed, and error limit reached, bail out here - if (!lastOp || (lastOp.response instanceof ErrorMessage && !(!this.#errorLimit || (this.#errorCount < this.#errorLimit)))) + if (!lastOp || (lastOp.response instanceof ErrorMessage && !(!errorLimit || (errorCount < errorLimit)))) return; } @@ -192,15 +169,15 @@ export class BulkOp { else if (method.toUpperCase() !== "DELETE" && (Object(data) !== data || Array.isArray(data))) error = new ErrorMessage(new Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value ${errorSuffix}`)) // Make sure any bulkIds referenced in data can eventually be resolved - else if (!waitingOn.every((id) => this.#bulkIds.has(id))) - error = new ErrorMessage(new Types.Error(400, "invalidValue", `No POST operation found matching bulkId '${waitingOn.find((id) => !this.#bulkIds.has(id))}'`)); + else if (!waitingOn.every((id) => bulkIds.has(id))) + error = new ErrorMessage(new Types.Error(400, "invalidValue", `No POST operation found matching bulkId '${waitingOn.find((id) => !bulkIds.has(id))}'`)); // If things look OK, attempt to apply the operation else { try { // Go through and wait on any referenced POST bulkIds for (let referenceId of waitingOn) { // Find the referenced operation to wait for - let reference = this.#bulkIds.get(referenceId), + let reference = bulkIds.get(referenceId), referenceIndex = bulkIdTransients.indexOf(referenceId); // If the reference is also waiting on us, we have ourselves a circular reference! @@ -213,7 +190,7 @@ export class BulkOp { // Set the ID for future use and resolve pending references jsonData = JSON.stringify(Object.assign(data, {id: id})); - this.#bulkIds.get(bulkId).resolve(id); + bulkIds.get(bulkId).resolve(id); } try { @@ -225,7 +202,7 @@ export class BulkOp { if (bulkId && id) await new TargetResource(id).dispose(); // If we're following on from a prior failure, no need to explain why, otherwise, explain the failure - if (ex instanceof ErrorMessage && (!!this.#errorLimit && this.#errorCount >= this.#errorLimit && index > lastErrorIndex)) return; + if (ex instanceof ErrorMessage && (!!errorLimit && errorCount >= errorLimit && index > lastErrorIndex)) return; else throw new Types.Error(412, null, `Referenced POST operation with bulkId '${referenceId}' was not successful`); } } @@ -239,7 +216,7 @@ export class BulkOp { case "POST": case "PUT": value = await resource.write(data); - if (bulkId && !resource.id && value.id) this.#bulkIds.get(bulkId).resolve(value.id); + if (bulkId && !resource.id && value.id) bulkIds.get(bulkId).resolve(value.id); Object.assign(result, {status: (!bulkId ? "200" : "201"), location: value?.meta?.location}); break; @@ -267,17 +244,16 @@ export class BulkOp { if (error instanceof ErrorMessage) { Object.assign(result, {status: error.status, response: error, location: (String(method).toUpperCase() !== "POST" ? result.location : undefined)}); lastErrorIndex = (index < lastErrorIndex ? index : lastErrorIndex); - this.#errorCount++; + errorCount++; // Also reject the pending bulkId promise as no resource ID can exist - if (bulkId && this.#bulkIds.has(bulkId)) this.#bulkIds.get(bulkId).reject(error); + if (bulkId && bulkIds.has(bulkId)) bulkIds.get(bulkId).reject(error); } return result; })()); - // Store the results and return the BulkOp for chaining - this.Operations.push(...(await Promise.all(results)).filter(r => r)); - return this; + // Await the results and return a new BulkResponse + return new BulkResponse((await Promise.all(results)).filter(r => r)); } } \ No newline at end of file diff --git a/test/lib/messages.js b/test/lib/messages.js index 5ec4b0a..ba8d22a 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -2,7 +2,7 @@ import assert from "assert"; import {ErrorSuite} from "./messages/error.js"; import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; -import {BulkOpSuite} from "./messages/bulkop.js"; +import {BulkRequestSuite} from "./messages/bulkop.js"; export let MessagesSuite = (SCIMMY) => { it("should include static class 'Messages'", () => @@ -12,6 +12,6 @@ export let MessagesSuite = (SCIMMY) => { ErrorSuite(SCIMMY); ListResponseSuite(SCIMMY); PatchOpSuite(SCIMMY); - BulkOpSuite(SCIMMY); + BulkRequestSuite(SCIMMY); }); } \ No newline at end of file diff --git a/test/lib/messages/bulkop.js b/test/lib/messages/bulkop.js index 30f57aa..df70a0a 100644 --- a/test/lib/messages/bulkop.js +++ b/test/lib/messages/bulkop.js @@ -3,15 +3,15 @@ import path from "path"; import url from "url"; import assert from "assert"; -export let BulkOpSuite = (SCIMMY) => { +export let BulkRequestSuite = (SCIMMY) => { const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./bulkop.json"), "utf8").then((f) => JSON.parse(f)); + const fixtures = fs.readFile(path.join(basepath, "./bulkrequest.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; const template = {schemas: [params.id], Operations: [{}, {}]}; /** - * BulkOp Test Resource Class - * Because BulkOp needs a set of implemented resources to test against + * BulkRequest Test Resource Class + * Because BulkRequest needs a set of implemented resources to test against */ class Test extends SCIMMY.Types.Resource { // Store some helpful things for the mock methods @@ -45,37 +45,37 @@ export let BulkOpSuite = (SCIMMY) => { } } - it("should include static class 'BulkOp'", () => - assert.ok(!!SCIMMY.Messages.BulkOp, "Static class 'BulkOp' not defined")); + it("should include static class 'BulkRequest'", () => + assert.ok(!!SCIMMY.Messages.BulkRequest, "Static class 'BulkRequest' not defined")); - describe("SCIMMY.Messages.BulkOp", () => { + describe("SCIMMY.Messages.BulkRequest", () => { it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: ["nonsense"]}), + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: ["nonsense"]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkOp instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: [params.id, "nonsense"]}), + "BulkRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: [params.id, "nonsense"]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkOp instantiated with invalid 'schemas' property"); + "BulkRequest instantiated with invalid 'schemas' property"); }); it("should expect 'Operations' attribute of 'request' argument to be an array", () => { - assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas, Operations: "a string"}), + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: "a string"}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, - "BulkOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + "BulkRequest instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); }); it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas}), + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.BulkOp({schemas: template.schemas, Operations: []}), + "BulkRequest instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: []}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkOp instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); + "BulkRequest instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); }); it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { @@ -87,10 +87,10 @@ export let BulkOpSuite = (SCIMMY) => { ]; for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkOp({...template, failOnErrors: value}), + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: value}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, - `BulkOp instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); + `BulkRequest instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); } }); @@ -103,34 +103,34 @@ export let BulkOpSuite = (SCIMMY) => { ]; for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkOp({...template}, value), + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, value), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, - `BulkOp instantiated with invalid 'maxOperations' parameter ${label}`); + `BulkRequest instantiated with invalid 'maxOperations' parameter ${label}`); } }); it("should expect number of operations to not exceed 'maxOperations' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkOp({...template}, 1), + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, 1), {name: "SCIMError", status: 413, scimType: null, message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, - "BulkOp instantiated with number of operations exceeding 'maxOperations' parameter"); + "BulkRequest instantiated with number of operations exceeding 'maxOperations' parameter"); }); describe("#apply()", () => { it("should have instance method 'apply'", () => { - assert.ok(typeof (new SCIMMY.Messages.BulkOp({...template})).apply === "function", + assert.ok(typeof (new SCIMMY.Messages.BulkRequest({...template})).apply === "function", "Instance method 'apply' not defined"); }); it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { - await assert.rejects(() => new SCIMMY.Messages.BulkOp({...template, failOnErrors: 1}).apply([{}]), - {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkOp"}, + await assert.rejects(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: 1}).apply([{}]), + {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkRequest"}, "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); }); it("should expect 'method' attribute to have a value for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations, expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) }})); @@ -147,7 +147,7 @@ export let BulkOpSuite = (SCIMMY) => { ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: value}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations, expected = [{status: "400", method: value, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) @@ -159,7 +159,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "a string"}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations, expected = [{status: "400", method: "a string", response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) }}]; @@ -169,7 +169,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'path' attribute to have a value for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations, expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) }})); @@ -186,7 +186,7 @@ export let BulkOpSuite = (SCIMMY) => { ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations, expected = [{status: "400", method: "POST", response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) @@ -198,7 +198,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations, expected = [{status: "400", method: "POST", response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) }}]; @@ -208,7 +208,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations, expected = [{status: "404", method: "POST", bulkId: "asdf", response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) }}]; @@ -218,7 +218,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations, expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) }})); @@ -228,7 +228,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations, expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) }})); @@ -245,7 +245,7 @@ export let BulkOpSuite = (SCIMMY) => { ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations, expected = [{status: "400", method: "POST", response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) @@ -257,7 +257,7 @@ export let BulkOpSuite = (SCIMMY) => { }); it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations, expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) }})); @@ -280,7 +280,7 @@ export let BulkOpSuite = (SCIMMY) => { for (let op of suite) { for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkOp({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations, + let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations, expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) @@ -295,11 +295,11 @@ export let BulkOpSuite = (SCIMMY) => { it("should stop processing operations when failOnErrors limit is reached", async () => { let {inbound: {failOnErrors: suite}} = await fixtures; - assert.ok((await (new SCIMMY.Messages.BulkOp({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, + assert.ok((await (new SCIMMY.Messages.BulkRequest({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, "Instance method 'apply' did not stop processing when failOnErrors limit reached"); for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not stop processing in inbound failOnErrors fixture #${suite.indexOf(fixture)+1}`); @@ -310,7 +310,7 @@ export let BulkOpSuite = (SCIMMY) => { let {inbound: {bulkId: {unordered: suite}}} = await fixtures; for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not resolve references in inbound bulkId unordered fixture #${suite.indexOf(fixture)+1}`); @@ -321,7 +321,7 @@ export let BulkOpSuite = (SCIMMY) => { let {inbound: {bulkId: {circular: suite}}} = await fixtures; for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkOp({...template, ...fixture.source})).apply([Test.reset()]); + let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); diff --git a/test/lib/messages/bulkop.json b/test/lib/messages/bulkrequest.json similarity index 100% rename from test/lib/messages/bulkop.json rename to test/lib/messages/bulkrequest.json From 1d4d0254a3bd156bcc03a5a9342e87fdc075ee14 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 13:51:39 +1100 Subject: [PATCH 12/93] Refactor(SCIMMY.Messages.BulkOp): end renaming BulkOp to BulkRequest --- src/lib/messages.js | 2 +- src/lib/messages/{bulkop.js => bulkrequest.js} | 0 test/lib/messages.js | 2 +- test/lib/messages/{bulkop.js => bulkrequest.js} | 0 4 files changed, 2 insertions(+), 2 deletions(-) rename src/lib/messages/{bulkop.js => bulkrequest.js} (100%) rename test/lib/messages/{bulkop.js => bulkrequest.js} (100%) diff --git a/src/lib/messages.js b/src/lib/messages.js index 62b3d72..e5aa963 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -1,7 +1,7 @@ import {Error} from "./messages/error.js"; import {ListResponse} from "./messages/listresponse.js"; import {PatchOp} from "./messages/patchop.js"; -import {BulkRequest} from "./messages/bulkop.js"; +import {BulkRequest} from "./messages/bulkrequest.js"; import {BulkResponse} from "./messages/bulkresponse.js"; /** diff --git a/src/lib/messages/bulkop.js b/src/lib/messages/bulkrequest.js similarity index 100% rename from src/lib/messages/bulkop.js rename to src/lib/messages/bulkrequest.js diff --git a/test/lib/messages.js b/test/lib/messages.js index ba8d22a..1ae1a10 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -2,7 +2,7 @@ import assert from "assert"; import {ErrorSuite} from "./messages/error.js"; import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; -import {BulkRequestSuite} from "./messages/bulkop.js"; +import {BulkRequestSuite} from "./messages/bulkrequest.js"; export let MessagesSuite = (SCIMMY) => { it("should include static class 'Messages'", () => diff --git a/test/lib/messages/bulkop.js b/test/lib/messages/bulkrequest.js similarity index 100% rename from test/lib/messages/bulkop.js rename to test/lib/messages/bulkrequest.js From 8b57ee3cd153bb24e1252603bac30b4af05c32c7 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 14:17:36 +1100 Subject: [PATCH 13/93] Chore(SCIMMY.Messages.BulkRequest): fix JSDoc parameter types and missing properties --- src/lib/messages/bulkrequest.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js index 4520196..f77a8d5 100644 --- a/src/lib/messages/bulkrequest.js +++ b/src/lib/messages/bulkrequest.js @@ -43,8 +43,9 @@ export class BulkRequest { * @param {Object} request - contents of the BulkRequest operation being performed * @param {Object[]} request.Operations - list of SCIM-compliant bulk operations to apply * @param {Number} [request.failOnErrors] - number of error results to encounter before aborting any following operations - * @param {Number} [maxOperations] - maximum number of operations supported in the request - * @property {Object[]} Operations - list of BulkResponse operation results + * @param {Number} [maxOperations] - maximum number of operations supported in the request, as specified by the service provider + * @property {Object[]} Operations - list of operations in this BulkRequest instance + * @property {Number} [failOnErrors] - number of error results a service provider should tolerate before aborting any following operations */ constructor(request, maxOperations = 0) { let {schemas = [], Operations: operations = [], failOnErrors = 0} = request ?? {}; @@ -74,7 +75,7 @@ export class BulkRequest { /** * Apply the operations specified by the supplied BulkRequest - * @param {SCIMMY.Types.Resource[]|*} [resourceTypes] - resource type classes to be used while processing bulk operations + * @param {SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing bulk operations, defaults to declared resources * @returns {SCIMMY.Messages.BulkResponse} a new BulkResponse Message instance with results of the requested operations */ async apply(resourceTypes = Object.values(Resources.declared())) { From bf084aacf7486229e7aa64509bbefce8fa61cb10 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 14:29:23 +1100 Subject: [PATCH 14/93] Tests(SCIMMY.Messages.BulkResponse): add initial suite of unit tests --- test/lib/messages.js | 2 ++ test/lib/messages/bulkresponse.js | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 test/lib/messages/bulkresponse.js diff --git a/test/lib/messages.js b/test/lib/messages.js index 1ae1a10..efd34e0 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -3,6 +3,7 @@ import {ErrorSuite} from "./messages/error.js"; import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; import {BulkRequestSuite} from "./messages/bulkrequest.js"; +import {BulkResponseSuite} from "./messages/bulkresponse.js"; export let MessagesSuite = (SCIMMY) => { it("should include static class 'Messages'", () => @@ -13,5 +14,6 @@ export let MessagesSuite = (SCIMMY) => { ListResponseSuite(SCIMMY); PatchOpSuite(SCIMMY); BulkRequestSuite(SCIMMY); + BulkResponseSuite(SCIMMY); }); } \ No newline at end of file diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js new file mode 100644 index 0000000..be44111 --- /dev/null +++ b/test/lib/messages/bulkresponse.js @@ -0,0 +1,26 @@ +import assert from "assert"; + +export let BulkResponseSuite = (SCIMMY) => { + const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; + const template = {schemas: [params.id], Operations: [{}, {}]}; + + it("should include static class 'BulkResponse'", () => + assert.ok(!!SCIMMY.Messages.BulkResponse, "Static class 'BulkResponse' not defined")); + + describe("SCIMMY.Messages.BulkResponse", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: ["nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: [params.id, "nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: template.schemas, Operations: "a string"}), + {name: "TypeError", message: "Expected 'Operations' property of 'request' parameter to be an array in BulkResponse constructor"}, + "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + }); +} \ No newline at end of file From 8eb002ad6712fcb849a59c4b8e60a43bdb3bdc18 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 17:35:10 +1100 Subject: [PATCH 15/93] Add(SCIMMY.Messages.BulkResponse): resolve method for reading new resource IDs --- src/lib/messages/bulkresponse.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/messages/bulkresponse.js b/src/lib/messages/bulkresponse.js index 1fa24e8..a7b98e2 100644 --- a/src/lib/messages/bulkresponse.js +++ b/src/lib/messages/bulkresponse.js @@ -20,18 +20,31 @@ export class BulkResponse { * @param {Object[]} [request.Operations] - list of applied SCIM-compliant bulk operation results, if request is an object * @property {Object[]} Operations - list of BulkResponse operation results */ - constructor(request) { + constructor(request = []) { let outbound = Array.isArray(request), operations = (outbound ? request : request?.Operations ?? []); // Verify the BulkResponse contents are valid - if (!outbound && Array.isArray(request.schemas) && (!request.schemas.includes(BulkResponse.#id) || request.schemas.length > 1)) + if (!outbound && Array.isArray(request?.schemas) && (!request.schemas.includes(BulkResponse.#id) || request.schemas.length > 1)) throw new TypeError(`BulkResponse request body messages must exclusively specify schema as '${BulkResponse.#id}'`); if (!Array.isArray(operations)) - throw new TypeError("Expected 'Operations' property of 'request' parameter to be an array in BulkResponse constructor"); + throw new TypeError("BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"); + if (!outbound && !operations.length) + throw new TypeError("BulkResponse request body must contain 'Operations' attribute with at least one operation"); // All seems ok, prepare the BulkResponse this.schemas = [BulkResponse.#id]; this.Operations = [...operations]; } + + /** + * Resolve bulkIds of POST operations into new resource IDs + * @returns {Map} map of bulkIds to resource IDs if operation was successful, or false if not + */ + resolve() { + return new Map(this.Operations + // Only target POST operations with valid bulkIds + .filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string") + .map(o => ([o.bulkId, (typeof o.location === "string" && !!o.location ? o.location.split("/").pop() : false)]))); + } } \ No newline at end of file From 78dba6d3b51e4e1de8b52f2d0786f01fa2f6a11f Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 17:43:56 +1100 Subject: [PATCH 16/93] Tests(SCIMMY.Messages.BulkResponse): add coverage of resolve method --- test/lib/messages/bulkresponse.js | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js index be44111..0def747 100644 --- a/test/lib/messages/bulkresponse.js +++ b/test/lib/messages/bulkresponse.js @@ -2,12 +2,17 @@ import assert from "assert"; export let BulkResponseSuite = (SCIMMY) => { const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; - const template = {schemas: [params.id], Operations: [{}, {}]}; + const template = {schemas: [params.id], Operations: []}; it("should include static class 'BulkResponse'", () => assert.ok(!!SCIMMY.Messages.BulkResponse, "Static class 'BulkResponse' not defined")); describe("SCIMMY.Messages.BulkResponse", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SCIMMY.Messages.BulkResponse())}, template, + "BulkResponse did not instantiate with correct default properties"); + }); + it("should not instantiate requests with invalid schemas", () => { assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: ["nonsense"]}), {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, @@ -19,8 +24,20 @@ export let BulkResponseSuite = (SCIMMY) => { it("should expect 'Operations' attribute of 'request' argument to be an array", () => { assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: template.schemas, Operations: "a string"}), - {name: "TypeError", message: "Expected 'Operations' property of 'request' parameter to be an array in BulkResponse constructor"}, + {name: "TypeError", message: "BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"}, "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); }); + + describe("#resolve()", () => { + it("should have instance method 'resolve'", () => { + assert.ok(typeof (new SCIMMY.Messages.BulkResponse()).resolve === "function", + "Instance method 'resolve' not defined"); + }); + + it("should return an instance of native Map class", () => { + assert.ok((new SCIMMY.Messages.BulkResponse().resolve()) instanceof Map, + "Instance method 'resolve' did not return a map"); + }); + }); }); } \ No newline at end of file From 932df7633a2ac94f931a5a3bb2394f843138d5ff Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 24 Nov 2021 17:53:57 +1100 Subject: [PATCH 17/93] Chore: bump version to 1.0.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 18cbc98..567d3ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "scimmy", "description": "SCIMMY - SCIM m(ade eas)y", - "version": "0.10.0", + "version": "1.0.0", "author": "sleelin", "license": "MIT", "type": "module", From 0af2d35dc7fc10b5f201328092a912f0b5e28d5c Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 25 Nov 2021 16:38:31 +1100 Subject: [PATCH 18/93] Fix(SCIMMY.Messages.PatchOp): replace of nonexistent multivalue targets --- src/lib/messages/patchop.js | 50 ++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index 1f32b4e..7ba8f2d 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -13,9 +13,9 @@ import Types from "../types.js"; */ const validOps = ["add", "remove", "replace"]; // Split a path by fullstops when they aren't in a filter group or decimal -const pathSeparator = /(?!((? p) + .map((p, i, s) => (i < s.length - 1 ? p : p.replace(multiValuedFilter, "$1"))).join("."), value); + } + } catch (ex) { + // Rethrow exceptions with 'replace' instead of 'add' or 'remove' + let forReplaceOp = "for 'replace' op"; + ex.message = ex.message.replace("for 'add' op", forReplaceOp).replace("for 'remove' op", forReplaceOp); + throw ex; + } + } } \ No newline at end of file From 6016c1b1ae22b22b36389c4fac25a90575d74b4b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 25 Nov 2021 16:42:45 +1100 Subject: [PATCH 19/93] Tests(SCIMMY.Messages.PatchOp): fixture for replace of nonexistent value --- test/lib/messages/patchop.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/lib/messages/patchop.json b/test/lib/messages/patchop.json index d0a9fff..658b9a4 100644 --- a/test/lib/messages/patchop.json +++ b/test/lib/messages/patchop.json @@ -37,6 +37,21 @@ "source": {"id": "1234", "userName": "asdf", "emails": [{"type": "home", "value": "asdf@dsaf.com"}]}, "target": {"id": "1234", "userName": "asdf", "emails": [{"type": "work", "value": "test@example.com"}]}, "ops": [{"op": "replace", "path": "emails", "value": {"type": "work", "value": "test@example.com"}}] + }, + { + "source": {"id": "1234", "userName": "asdf", "emails": [{"type": "home", "value": "asdf@dsaf.com"}]}, + "target": {"id": "1234", "userName": "asdf", "emails": [{"type": "home", "value": "asdf@dsaf.com"}, {"type": "work", "value": "test@example.com"}]}, + "ops": [{"op": "replace", "path": "emails[type eq \"work\"]", "value": {"type": "work", "value": "test@example.com"}}] + }, + { + "source": {"id": "1234", "userName": "asdf", "emails": [{"type": "work", "value": "asdf@dsaf.com"}]}, + "target": {"id": "1234", "userName": "asdf", "emails": [{"type": "work", "value": "test@example.com"}]}, + "ops": [{"op": "replace", "path": "emails[type eq \"work\"]", "value": {"type": "work", "value": "test@example.com"}}] + }, + { + "source": {"id": "1234", "userName": "asdf", "emails": [{"type": "work", "value": "asdf@dsaf.com"}]}, + "target": {"id": "1234", "userName": "asdf", "emails": [{"type": "work", "value": "test@example.com"}]}, + "ops": [{"op": "replace", "path": "emails[type eq \"work\"].value", "value": "test@example.com"}] } ] } From 255eb4fe2a22a12c0b0bddc6fc13b02b5be7ef2b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 25 Nov 2021 18:21:04 +1100 Subject: [PATCH 20/93] Fix(SCIMMY.Messages.BulkRequest): add catch for rejected bulkId promise --- src/lib/messages/bulkrequest.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js index f77a8d5..90be633 100644 --- a/src/lib/messages/bulkrequest.js +++ b/src/lib/messages/bulkrequest.js @@ -97,7 +97,7 @@ export class BulkRequest { .map(({bulkId}, index, postOps) => { // Establish who waits on what, and provide a way for that to happen let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, - value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})); + value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})).catch((e) => e); return [bulkId, Object.assign(value, handlers)]; }) From 0e79731e290cc887522be89bd3af0a48bcfa2d6d Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 26 Nov 2021 16:20:40 +1100 Subject: [PATCH 21/93] Add(SCIMMY.Messages.SearchRequest): initial implementation of search request message class --- src/lib/messages.js | 2 + src/lib/messages/searchrequest.js | 63 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/lib/messages/searchrequest.js diff --git a/src/lib/messages.js b/src/lib/messages.js index e5aa963..4701e1a 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -3,6 +3,7 @@ import {ListResponse} from "./messages/listresponse.js"; import {PatchOp} from "./messages/patchop.js"; import {BulkRequest} from "./messages/bulkrequest.js"; import {BulkResponse} from "./messages/bulkresponse.js"; +import {SearchRequest} from "./messages/searchrequest.js"; /** * SCIMMY Messages Container Class @@ -17,4 +18,5 @@ export default class Messages { static PatchOp = PatchOp; static BulkRequest = BulkRequest; static BulkResponse = BulkResponse; + static SearchRequest = SearchRequest; } \ No newline at end of file diff --git a/src/lib/messages/searchrequest.js b/src/lib/messages/searchrequest.js new file mode 100644 index 0000000..9389d35 --- /dev/null +++ b/src/lib/messages/searchrequest.js @@ -0,0 +1,63 @@ +import Types from "../types.js"; + +/** + * SCIM Search Request Message Type + * @alias SCIMMY.Messages.SearchRequest + * @since 1.0.0 + * @summary + * * Encapsulates HTTP POST data as [SCIM SearchRequest messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.3). + * * Provides a method to perform the search request against the declared or specified resource types. + */ +export class SearchRequest { + /** + * SCIM SearchRequest Message Schema ID + * @type {String} + * @private + */ + static #id = "urn:ietf:params:scim:api:messages:2.0:SearchRequest"; + + /** + * Instantiate a new SCIM SearchRequest message from the supplied request + * @param {Object} request - contents of the SearchRequest received by the service provider + * @param {String} [request.filter] - the filter to be applied on ingress/egress by implementing resource + * @param {String[]} [request.excludedAttributes] - the string list of attributes or filters to exclude on egress + * @param {String[]} [request.attributes] - the string list of attributes or filters to include on egress + * @param {String} [request.sortBy] - the attribute retrieved resources should be sorted by + * @param {String} [request.sortOrder] - the direction retrieved resources should be sorted in + * @param {Number} [request.startIndex] - offset index that retrieved resources should start from + * @param {Number} [request.count] - maximum number of retrieved resources that should be returned in one operation + * @property {String} [filter] - the filter to be applied on ingress/egress by implementing resource + * @property {String[]} [excludedAttributes] - the string list of attributes or filters to exclude on egress + * @property {String[]} [attributes] - the string list of attributes or filters to include on egress + * @property {String} [sortBy] - the attribute retrieved resources should be sorted by + * @property {String} [sortOrder] - the direction retrieved resources should be sorted in + * @property {Number} [startIndex] - offset index that retrieved resources should start from + * @property {Number} [count] - maximum number of retrieved resources that should be returned in one operation + */ + constructor(request) { + let {schemas = [], filter, excludedAttributes = [], attributes = [], sortBy, sortOrder, startIndex, count} = request ?? {}; + + // Verify the BulkResponse contents are valid + if (Array.isArray(schemas) && ((schemas.length === 1 && !schemas.includes(SearchRequest.#id) || schemas.length > 1))) + throw new TypeError(`SearchRequest request body messages must exclusively specify schema as '${SearchRequest.#id}'`); + // Bail out if filter isn't a non-empty string + if (filter !== undefined && (typeof filter !== "string" || !filter.trim().length)) + throw new Types.Error(400, "invalidFilter", "Expected filter to be a non-empty string"); + // Bail out if excludedAttributes isn't an array of non-empty strings + if (!Array.isArray(excludedAttributes) || !excludedAttributes.every((a) => (typeof a !== "string" || !a.trim().length))) + throw new Types.Error(400, "invalidFilter", "Expected excludedAttributes to be an array of non-empty strings"); + // Bail out if attributes isn't an array of non-empty strings + if (!Array.isArray(attributes) || !attributes.every((a) => (typeof a !== "string" || !a.trim().length))) + throw new Types.Error(400, "invalidFilter", "Expected attributes to be an array of non-empty strings"); + + // All seems ok, prepare the SearchRequest + this.schemas = [SearchRequest.#id]; + if (!!filter) this.filter = filter; + if (excludedAttributes.length) this.excludedAttributes = [...excludedAttributes]; + if (attributes.length) this.attributes = [...attributes]; + if (sortBy !== undefined) this.sortBy = sortBy; + if (["ascending", "descending"].includes(sortOrder)) this.sortOrder = sortOrder; + if (startIndex !== undefined) this.startIndex = startIndex; + if (count !== undefined) this.count = count; + } +} \ No newline at end of file From 2e0beed92f0eeffd9b45884979b1a0c7d8f16a43 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 26 Nov 2021 17:04:53 +1100 Subject: [PATCH 22/93] Add(SCIMMY.Messages.SearchRequest): apply method for performing search --- src/lib/messages/searchrequest.js | 43 +++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/lib/messages/searchrequest.js b/src/lib/messages/searchrequest.js index 9389d35..4791ff8 100644 --- a/src/lib/messages/searchrequest.js +++ b/src/lib/messages/searchrequest.js @@ -1,4 +1,6 @@ +import {ListResponse} from "./listresponse.js"; import Types from "../types.js"; +import Resources from "../resources.js"; /** * SCIM Search Request Message Type @@ -44,10 +46,10 @@ export class SearchRequest { if (filter !== undefined && (typeof filter !== "string" || !filter.trim().length)) throw new Types.Error(400, "invalidFilter", "Expected filter to be a non-empty string"); // Bail out if excludedAttributes isn't an array of non-empty strings - if (!Array.isArray(excludedAttributes) || !excludedAttributes.every((a) => (typeof a !== "string" || !a.trim().length))) + if (!Array.isArray(excludedAttributes) || !excludedAttributes.every((a) => (typeof a === "string" && !!a.trim().length))) throw new Types.Error(400, "invalidFilter", "Expected excludedAttributes to be an array of non-empty strings"); // Bail out if attributes isn't an array of non-empty strings - if (!Array.isArray(attributes) || !attributes.every((a) => (typeof a !== "string" || !a.trim().length))) + if (!Array.isArray(attributes) || !attributes.every((a) => (typeof a === "string" && !!a.trim().length))) throw new Types.Error(400, "invalidFilter", "Expected attributes to be an array of non-empty strings"); // All seems ok, prepare the SearchRequest @@ -60,4 +62,41 @@ export class SearchRequest { if (startIndex !== undefined) this.startIndex = startIndex; if (count !== undefined) this.count = count; } + + /** + * Apply a search request operation, retrieving results from specified resource types + * @param {SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing the search request, defaults to declared resources + * @returns {SCIMMY.Messages.ListResponse} a ListResponse message with results of the search request + */ + async apply(resourceTypes = Object.values(Resources.declared())) { + // Make sure all specified resource types extend the Resource type class so operations can be processed correctly + if (!Array.isArray(resourceTypes) || !resourceTypes.every(r => r.prototype instanceof Types.Resource)) + throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of SearchRequest"); + + // Build the common request template + let request = { + ...(!!this.filter ? {filter: this.filter} : {}), + ...(!!this.excludedAttributes ? {excludedAttributes: this.excludedAttributes.join(",")} : {}), + ...(!!this.attributes ? {attributes: this.attributes.join(",")} : {}) + } + + // If only one resource type, just read from it + if (resourceTypes.length === 1) { + let [Resource] = resourceTypes; + return new Resource({...this, ...request}).read(); + } + // Otherwise, read from all resources and return collected results + else { + // Read from, and unwrap results for, supplied resource types + let results = await Promise.all(resourceTypes.map((Resource) => new Resource(request).read())) + .then((r) => r.map((l) => l.Resources)); + + // Collect the results in a list response with specified constraints + return new ListResponse(results.flat(Infinity), { + sortBy: this.sortBy, sortOrder: this.sortOrder, + ...(!!this.startIndex ? {startIndex: Number(this.startIndex)} : {}), + ...(!!this.count ? {itemsPerPage: Number(this.count)} : {}) + }); + } + } } \ No newline at end of file From 87928adf340984d938e16125bda8a10f0c6582ab Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 26 Nov 2021 22:09:32 +1100 Subject: [PATCH 23/93] Tests(SCIMMY.Messages.SearchRequest): add initial suite of unit tests --- test/lib/messages.js | 2 + test/lib/messages/searchrequest.js | 231 +++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 test/lib/messages/searchrequest.js diff --git a/test/lib/messages.js b/test/lib/messages.js index efd34e0..72ad4c3 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -4,6 +4,7 @@ import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; import {BulkRequestSuite} from "./messages/bulkrequest.js"; import {BulkResponseSuite} from "./messages/bulkresponse.js"; +import {SearchRequestSuite} from "./messages/searchrequest.js"; export let MessagesSuite = (SCIMMY) => { it("should include static class 'Messages'", () => @@ -15,5 +16,6 @@ export let MessagesSuite = (SCIMMY) => { PatchOpSuite(SCIMMY); BulkRequestSuite(SCIMMY); BulkResponseSuite(SCIMMY); + SearchRequestSuite(SCIMMY); }); } \ No newline at end of file diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js new file mode 100644 index 0000000..3ba52b6 --- /dev/null +++ b/test/lib/messages/searchrequest.js @@ -0,0 +1,231 @@ +import assert from "assert"; + +export let SearchRequestSuite = (SCIMMY) => { + const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; + const template = {schemas: [params.id]}; + const suites = { + strings: [ + ["empty string value", ""], + ["boolean value 'false'", false], + ["number value '1'", 1], + ["complex value", {}] + ], + numbers: [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["decimal value '1.5'", 1.5], + ["complex value", {}] + ], + arrays: [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["number value '1'", 1], + ["complex value", {}], + ["array with an empty string", ["test", ""]] + ] + }; + + it("should include static class 'SearchRequest'", () => + assert.ok(!!SCIMMY.Messages.SearchRequest, "Static class 'SearchRequest' not defined")); + + describe("SCIMMY.Messages.SearchRequest", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SCIMMY.Messages.SearchRequest())}, template, + "SearchRequest did not instantiate with correct default properties"); + }); + + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + }); + + it("should expect 'filter' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, filter: "test"}), + "SearchRequest did not instantiate with valid 'filter' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, filter: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'filter' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'filter' property ${label}`); + } + }); + + it("should expect 'excludedAttributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'sortBy' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: "test"}), + "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortBy' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'sortBy' property ${label}`); + } + }); + + it("should expect 'sortOrder' property of 'request' argument to be either 'ascending' or 'descending', if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: "ascending"}), + "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, + `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); + } + }); + + it("should expect 'startIndex' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: 1}), + "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'startIndex' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'startIndex' property ${label}`); + } + }); + + it("should expect 'count' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, count: 1}), + "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, count: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'count' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'count' property ${label}`); + } + }); + + describe("#prepare()", () => { + it("should have instance method 'prepare'", () => { + assert.ok(typeof (new SCIMMY.Messages.SearchRequest()).prepare === "function", + "Instance method 'prepare' not defined"); + }); + + it("should return the same instance it was called from", () => { + let expected = new SCIMMY.Messages.SearchRequest(); + + assert.strictEqual(expected.prepare(), expected, + "Instance method 'prepare' did not return the same instance it was called from"); + }); + + it("should expect 'filter' property of 'params' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({filter: "test"}), + "Instance method 'prepare' rejected valid 'filter' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({filter: value}), + {name: "TypeError", message: "Expected 'filter' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'filter' property ${label}`); + } + }); + + it("should expect 'excludedAttributes' property of 'params' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({excludedAttributes: ["test"]}), + "Instance method 'prepare' rejected valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({excludedAttributes: value}), + {name: "TypeError", message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'attributes' property of 'request' params to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({attributes: ["test"]}), + "Instance method 'prepare' rejected valid 'attributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({attributes: value}), + {name: "TypeError", message: "Expected 'attributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'attributes' property ${label}`); + } + }); + + it("should expect 'sortBy' property of 'params' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({sortBy: "test"}), + "Instance method 'prepare' rejected valid 'sortBy' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({sortBy: value}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'sortBy' property ${label}`); + } + }); + + it("should expect 'sortOrder' property of 'params' argument to be either 'ascending' or 'descending', if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({sortOrder: "ascending"}), + "Instance method 'prepare' rejected valid 'sortOrder' property string value 'ascending'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({sortOrder: value}), + {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'sortOrder' property ${label}`); + } + }); + + it("should expect 'startIndex' property of 'params' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({startIndex: 1}), + "Instance method 'prepare' rejected valid 'startIndex' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({startIndex: value}), + {name: "TypeError", message: "Expected 'startIndex' parameter to be a positive integer in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'startIndex' property ${label}`); + } + }); + + it("should expect 'count' property of 'params' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({count: 1}), + "Instance method 'prepare' rejected valid 'count' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({count: value}), + {name: "TypeError", message: "Expected 'count' parameter to be a positive integer in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'count' property ${label}`); + } + }); + }); + + describe("#apply()", () => { + it("should have instance method 'apply'", () => { + assert.ok(typeof (new SCIMMY.Messages.SearchRequest()).apply === "function", + "Instance method 'apply' not defined"); + }); + }); + }); +} \ No newline at end of file From abf0d6904fe05071c1c08dc33496941a6c898f0b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 26 Nov 2021 22:11:01 +1100 Subject: [PATCH 24/93] Add(SCIMMY.Messages.SearchRequest): prepare method so outgoing requests can be constructed --- src/lib/messages/searchrequest.js | 65 ++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/lib/messages/searchrequest.js b/src/lib/messages/searchrequest.js index 4791ff8..a03ddf9 100644 --- a/src/lib/messages/searchrequest.js +++ b/src/lib/messages/searchrequest.js @@ -20,7 +20,7 @@ export class SearchRequest { /** * Instantiate a new SCIM SearchRequest message from the supplied request - * @param {Object} request - contents of the SearchRequest received by the service provider + * @param {Object} [request] - contents of the SearchRequest received by the service provider * @param {String} [request.filter] - the filter to be applied on ingress/egress by implementing resource * @param {String[]} [request.excludedAttributes] - the string list of attributes or filters to exclude on egress * @param {String[]} [request.attributes] - the string list of attributes or filters to include on egress @@ -37,23 +37,60 @@ export class SearchRequest { * @property {Number} [count] - maximum number of retrieved resources that should be returned in one operation */ constructor(request) { - let {schemas = [], filter, excludedAttributes = [], attributes = [], sortBy, sortOrder, startIndex, count} = request ?? {}; + let {schemas} = request ?? {}; - // Verify the BulkResponse contents are valid - if (Array.isArray(schemas) && ((schemas.length === 1 && !schemas.includes(SearchRequest.#id) || schemas.length > 1))) - throw new TypeError(`SearchRequest request body messages must exclusively specify schema as '${SearchRequest.#id}'`); - // Bail out if filter isn't a non-empty string + // Verify the SearchRequest contents are valid + if (request !== undefined && (!Array.isArray(schemas) || ((schemas.length === 1 && !schemas.includes(SearchRequest.#id)) || schemas.length > 1))) + throw new Types.Error(400, "invalidSyntax", `SearchRequest request body messages must exclusively specify schema as '${SearchRequest.#id}'`); + + try { + // All seems ok, prepare the SearchRequest + this.schemas = [SearchRequest.#id]; + this.prepare(request); + } catch (ex) { + // Rethrow TypeErrors from prepare as SCIM Errors + throw new Types.Error(400, "invalidValue", ex.message.replace(" in 'prepare' method of SearchRequest", "")); + } + } + + /** + * Prepare a new search request for transmission to a service provider + * @param {Object} [params] - details of the search request to be sent to a service provider + * @param {String} [params.filter] - the filter to be applied on ingress/egress by implementing resource + * @param {String[]} [params.excludedAttributes] - the string list of attributes or filters to exclude on egress + * @param {String[]} [params.attributes] - the string list of attributes or filters to include on egress + * @param {String} [params.sortBy] - the attribute retrieved resources should be sorted by + * @param {String} [params.sortOrder] - the direction retrieved resources should be sorted in + * @param {Number} [params.startIndex] - offset index that retrieved resources should start from + * @param {Number} [params.count] - maximum number of retrieved resources that should be returned in one operation + * @returns {SCIMMY.Messages.SearchRequest} this SearchRequest instance for chaining + */ + prepare(params = {}) { + let {filter, excludedAttributes = [], attributes = [], sortBy, sortOrder, startIndex, count} = params; + + // Make sure filter is a non-empty string, if specified if (filter !== undefined && (typeof filter !== "string" || !filter.trim().length)) - throw new Types.Error(400, "invalidFilter", "Expected filter to be a non-empty string"); - // Bail out if excludedAttributes isn't an array of non-empty strings + throw new TypeError("Expected 'filter' parameter to be a non-empty string in 'prepare' method of SearchRequest"); + // Make sure excludedAttributes is an array of non-empty strings if (!Array.isArray(excludedAttributes) || !excludedAttributes.every((a) => (typeof a === "string" && !!a.trim().length))) - throw new Types.Error(400, "invalidFilter", "Expected excludedAttributes to be an array of non-empty strings"); - // Bail out if attributes isn't an array of non-empty strings + throw new TypeError("Expected 'excludedAttributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"); + // Make sure attributes is an array of non-empty strings if (!Array.isArray(attributes) || !attributes.every((a) => (typeof a === "string" && !!a.trim().length))) - throw new Types.Error(400, "invalidFilter", "Expected attributes to be an array of non-empty strings"); + throw new TypeError("Expected 'attributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"); + // Make sure sortBy is a non-empty string, if specified + if (sortBy !== undefined && (typeof sortBy !== "string" || !sortBy.trim().length)) + throw new TypeError("Expected 'sortBy' parameter to be a non-empty string in 'prepare' method of SearchRequest"); + // Make sure sortOrder is a non-empty string, if specified + if (sortOrder !== undefined && !["ascending", "descending"].includes(sortOrder)) + throw new TypeError("Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in 'prepare' method of SearchRequest"); + // Make sure startIndex is a positive integer, if specified + if (startIndex !== undefined && (typeof startIndex !== "number" || !Number.isInteger(startIndex) || startIndex < 1)) + throw new TypeError("Expected 'startIndex' parameter to be a positive integer in 'prepare' method of SearchRequest"); + // Make sure count is a positive integer, if specified + if (count !== undefined && (typeof count !== "number" || !Number.isInteger(count) || count < 1)) + throw new TypeError("Expected 'count' parameter to be a positive integer in 'prepare' method of SearchRequest"); - // All seems ok, prepare the SearchRequest - this.schemas = [SearchRequest.#id]; + // Sanity checks have passed, assign values if (!!filter) this.filter = filter; if (excludedAttributes.length) this.excludedAttributes = [...excludedAttributes]; if (attributes.length) this.attributes = [...attributes]; @@ -61,6 +98,8 @@ export class SearchRequest { if (["ascending", "descending"].includes(sortOrder)) this.sortOrder = sortOrder; if (startIndex !== undefined) this.startIndex = startIndex; if (count !== undefined) this.count = count; + + return this; } /** From a84e71ba9530c2e02f3d8c33145d073865269e4a Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 27 Nov 2021 16:16:15 +1100 Subject: [PATCH 25/93] Tests(SCIMMY.Messages.SearchRequest): basic tests for apply method --- test/lib/messages/searchrequest.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index 3ba52b6..d0dde66 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -226,6 +226,17 @@ export let SearchRequestSuite = (SCIMMY) => { assert.ok(typeof (new SCIMMY.Messages.SearchRequest()).apply === "function", "Instance method 'apply' not defined"); }); + + it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { + await assert.rejects(() => new SCIMMY.Messages.SearchRequest().apply([{}]), + {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of SearchRequest"}, + "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); + }); + + it("should return a ListResponse message instance", async () => { + assert.ok(await (new SCIMMY.Messages.SearchRequest()).apply() instanceof SCIMMY.Messages.ListResponse, + "Instance method 'apply' did not return an instance of ListResponse"); + }); }); }); } \ No newline at end of file From 96ab5eada4dbae2c049af8fc7140b387f1966bf3 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 27 Nov 2021 16:24:47 +1100 Subject: [PATCH 26/93] Fix(SCIMMY.Resources.User): handle when egress returns undefined --- src/lib/resources/user.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/resources/user.js b/src/lib/resources/user.js index 1fc68f1..0ac459c 100644 --- a/src/lib/resources/user.js +++ b/src/lib/resources/user.js @@ -82,11 +82,11 @@ export class User extends Types.Resource { */ async read() { if (!this.id) { - return new Messages.ListResponse((await User.#egress(this)) + return new Messages.ListResponse((await User.#egress(this) ?? []) .map(u => new Schemas.User(u, "out", User.basepath(), this.attributes)), this.constraints); } else { try { - return new Schemas.User((await User.#egress(this)).shift(), "out", User.basepath(), this.attributes); + return new Schemas.User((await User.#egress(this) ?? []).shift(), "out", User.basepath(), this.attributes); } catch (ex) { if (ex instanceof Types.Error) throw ex; else if (ex instanceof TypeError) throw new Types.Error(400, "invalidValue", ex.message); @@ -140,7 +140,7 @@ export class User extends Types.Resource { try { return await new Messages.PatchOp(message) - .apply(new Schemas.User((await User.#egress(this)).shift(), "out"), + .apply(new Schemas.User((await User.#egress(this) ?? []).shift(), "out"), async (instance) => await User.#ingress(this, instance)) .then(instance => !instance ? undefined : new Schemas.User(instance, "out", User.basepath(), this.attributes)); } catch (ex) { From 229fa1dfdd3de713027ee3bdc5ec011567f3994a Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 27 Nov 2021 16:25:18 +1100 Subject: [PATCH 27/93] Fix(SCIMMY.Resources.Group): handle when egress returns undefined --- src/lib/resources/group.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/resources/group.js b/src/lib/resources/group.js index 5d322ea..dd2b6d5 100644 --- a/src/lib/resources/group.js +++ b/src/lib/resources/group.js @@ -82,11 +82,11 @@ export class Group extends Types.Resource { */ async read() { if (!this.id) { - return new Messages.ListResponse((await Group.#egress(this)) + return new Messages.ListResponse((await Group.#egress(this) ?? []) .map(u => new Schemas.Group(u, "out", Group.basepath(), this.attributes)), this.constraints); } else { try { - return new Schemas.Group((await Group.#egress(this)).shift(), "out", Group.basepath(), this.attributes); + return new Schemas.Group((await Group.#egress(this) ?? []).shift(), "out", Group.basepath(), this.attributes); } catch (ex) { if (ex instanceof Types.Error) throw ex; else if (ex instanceof TypeError) throw new Types.Error(400, "invalidValue", ex.message); @@ -140,7 +140,7 @@ export class Group extends Types.Resource { try { return await new Messages.PatchOp(message) - .apply(new Schemas.Group((await Group.#egress(this)).shift(), "out"), + .apply(new Schemas.Group((await Group.#egress(this) ?? []).shift(), "out"), async (instance) => await Group.#ingress(this, instance)) .then(instance => !instance ? undefined : new Schemas.Group(instance, "out", Group.basepath(), this.attributes)); } catch (ex) { From 386cd8fc02331a9d34a02b8c4c4b987a889a88f1 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 27 Nov 2021 16:26:50 +1100 Subject: [PATCH 28/93] Chore(SCIMMY.Types.Resource): wrong word in explanatory comment --- src/lib/types/resource.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 91b5c57..12e33b0 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -197,7 +197,7 @@ export class Resource { } // Parse the filter if it exists, and wasn't set by ID above else if ("filter" in params) { - // Bail out if attributes isn't a non-empty string + // Bail out if filter isn't a non-empty string if (typeof params.filter !== "string" || !params.filter.trim().length) throw new SCIMError(400, "invalidFilter", "Expected filter to be a non-empty string"); From a11329434f07d79ad4a6d31baa7cce3a50c991f4 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sun, 28 Nov 2021 15:58:19 +1100 Subject: [PATCH 29/93] Add(SCIMMY.Types.Attribute): config validation post-instantiation --- src/lib/types/attribute.js | 215 +++++++++++++++++++++---------------- 1 file changed, 120 insertions(+), 95 deletions(-) diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index 9ad2f80..7375a84 100644 --- a/src/lib/types/attribute.js +++ b/src/lib/types/attribute.js @@ -1,50 +1,112 @@ /** - * Collection of valid attribute type characteristic's values - * @enum - * @inner - * @constant - * @type {String[]} - * @alias ValidAttributeTypes - * @memberOf SCIMMY.Types.Attribute - * @default - */ -const types = ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"]; - -/** - * Collection of valid attribute mutability characteristic's values - * @enum - * @inner - * @constant - * @type {String[]} - * @alias ValidMutabilityValues - * @memberOf SCIMMY.Types.Attribute - * @default - */ -const mutability = ["readOnly", "readWrite", "immutable", "writeOnly"]; - -/** - * Collection of valid attribute returned characteristic's values - * @enum - * @inner - * @constant - * @type {String[]} - * @alias ValidReturnedValues - * @memberOf SCIMMY.Types.Attribute - * @default + * Base Attribute configuration, and proxied configuration validation trap handler + * @type {{target: SCIMMY.Types.Attribute~AttributeConfig, handler: ProxiedConfigHandler}} + * @private */ -const returned = ["always", "never", "default", "request"]; +const BaseConfiguration = { + /** + * @typedef {Object} SCIMMY.Types.Attribute~AttributeConfig + * @property {Boolean} [multiValued=false] - does the attribute expect a collection of values + * @property {String} [description=""] - a human-readable description of the attribute + * @property {Boolean} [required=false] - whether the attribute is required for the type instance to be valid + * @property {Boolean|String[]} [canonicalValues=false] - values the attribute's contents must be set to + * @property {Boolean} [caseExact=false] - whether the attribute's contents is case sensitive + * @property {Boolean|String} [mutable=true] - whether the attribute's contents is modifiable + * @property {Boolean|String} [returned=true] - whether the attribute is returned in a response + * @property {Boolean|String[]} [referenceTypes=false] - list of referenced types if attribute type is reference + * @property {String|Boolean} [uniqueness="none"] - the attribute's uniqueness characteristic + * @property {String} [direction="both"] - whether the attribute should be present for inbound, outbound, or bidirectional requests + */ + target: { + required: false, mutable: true, multiValued: false, caseExact: false, returned: true, + description: "", canonicalValues: false, referenceTypes: false, uniqueness: "none", direction: "both" + }, + + /** + * Proxied configuration validation trap handler + * @alias ProxiedConfigHandler + * @param {String} errorSuffix - the suffix to use in thrown type errors + * @returns {{set: (function(Object, String, *): boolean)}} the handler trap definition to use in the config proxy + * @private + */ + handler: (errorSuffix) => ({ + set: (target, key, value) => { + // Make sure required, multiValued, and caseExact are booleans + if (["required", "multiValued", "caseExact"].includes(key) && (value !== undefined && typeof value !== "boolean")) + throw new TypeError(`Attribute '${key}' value must be either 'true' or 'false' in ${errorSuffix}`); + // Make sure canonicalValues and referenceTypes are valid if they are specified + if (["canonicalValues", "referenceTypes"].includes(key) && (value !== undefined && value !== false && !Array.isArray(value))) + throw new TypeError(`Attribute '${key}' value must be either a collection or 'false' in ${errorSuffix}`); + // Make sure mutability, returned, and uniqueness config values are valid + if (["mutable", "returned", "uniqueness"].includes(key)) { + let label = (key === "mutable" ? "mutability" : key); + + if ((typeof value === "string" && !CharacteristicValidity[label].includes(value))) + throw new TypeError(`Attribute '${label}' value '${value}' not recognised in ${errorSuffix}`); + else if (value !== undefined && !["string", "boolean"].includes(typeof value)) + throw new TypeError(`Attribute '${label}' value must be either string or boolean in ${errorSuffix}`); + } + + // Set the value! + return (target[key] = value) || true; + } + }) +}; /** - * Collection of valid attribute uniqueness characteristic's values - * @enum - * @inner - * @constant - * @type {String[]} - * @alias ValidUniquenessValues - * @memberOf SCIMMY.Types.Attribute - * @default + * Valid values for various Attribute characteristics + * @type {{types: ValidAttributeTypes, mutability: ValidMutabilityValues, returned: ValidReturnedValues, uniqueness: ValidUniquenessValues}} + * @private */ -const uniqueness = ["none", "server", "global"]; +const CharacteristicValidity = { + /** + * Collection of valid attribute type characteristic's values + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidAttributeTypes + * @memberOf SCIMMY.Types.Attribute + * @default + */ + types: ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"], + + /** + * Collection of valid attribute mutability characteristic's values + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidMutabilityValues + * @memberOf SCIMMY.Types.Attribute + * @default + */ + mutability: ["readOnly", "readWrite", "immutable", "writeOnly"], + + /** + * Collection of valid attribute returned characteristic's values + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidReturnedValues + * @memberOf SCIMMY.Types.Attribute + * @default + */ + returned: ["always", "never", "default", "request"], + + /** + * Collection of valid attribute uniqueness characteristic's values + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidUniquenessValues + * @memberOf SCIMMY.Types.Attribute + * @default + */ + uniqueness: ["none", "server", "global"] +}; /** * Attribute value validation method container @@ -231,20 +293,6 @@ const validate = { * * Defines a SCIM schema attribute, and is used to ensure a given resource's value conforms to the attribute definition. */ export class Attribute { - /** - * @typedef {Object} SCIMMY.Types.Attribute~AttributeConfig - * @property {Boolean} [multiValued=false] - does the attribute expect a collection of values - * @property {String} [description=""] - a human-readable description of the attribute - * @property {Boolean} [required=false] - whether the attribute is required for the type instance to be valid - * @property {Boolean|String[]} [canonicalValues=false] - values the attribute's contents must be set to - * @property {Boolean} [caseExact=false] - whether the attribute's contents is case sensitive - * @property {Boolean|String} [mutable=true] - whether the attribute's contents is modifiable - * @property {Boolean|String} [returned=true] - whether the attribute is returned in a response - * @property {Boolean|String[]} [referenceTypes=false] - list of referenced types if attribute type is reference - * @property {String|Boolean} [uniqueness="none"] - the attribute's uniqueness characteristic - * @property {String} [direction="both"] - whether the attribute should be present for inbound, outbound, or bidirectional requests - */ - /** * Constructs an instance of a full SCIM attribute definition * @param {String} type - the data type of the attribute @@ -258,59 +306,34 @@ export class Attribute { */ constructor(type, name, config = {}, subAttributes = []) { let errorSuffix = `attribute definition '${name}'`, - // Collect type and name values for validation - safelyTyped = [["type", type], ["name", name]], - // Collect canonicalValues and referenceTypes values for validation - safelyCollected = [["canonicalValues", config.canonicalValues], ["referenceTypes", config.referenceTypes]], - // Collect mutability, returned, and uniqueness values for validation - safelyConfigured = [ - ["mutability", config.mutable, mutability], - ["returned", config.returned, returned], - ["uniqueness", config.uniqueness, uniqueness] - ], - // Make sure attribute name is valid + // Check for invalid characters in attribute name [, invalidNameChar] = /^(?:.*?)([^$\-_a-zA-Z0-9])(?:.*?)$/g.exec(name) ?? []; - // Make sure name and type are supplied, and type is valid - for (let [param, value] of safelyTyped) if (typeof value !== "string") + // Make sure name and type are supplied as strings + for (let [param, value] of [["type", type], ["name", name]]) if (typeof value !== "string") throw new TypeError(`Required parameter '${param}' missing from Attribute instantiation`); - if (!types.includes(type)) + // Make sure type is valid + if (!CharacteristicValidity.types.includes(type)) throw new TypeError(`Type '${type}' not recognised in ${errorSuffix}`); + // Make sure name is valid if (!!invalidNameChar) throw new TypeError(`Invalid character '${invalidNameChar}' in name of ${errorSuffix}`); - - // Make sure mutability, returned, and uniqueness config values are valid - for (let [key, value, values] of safelyConfigured) { - if ((typeof value === "string" && !values.includes(value))) { - throw new TypeError(`Attribute '${key}' value '${value}' not recognised in ${errorSuffix}`); - } else if (value !== undefined && !["string", "boolean"].includes(typeof value)) { - throw new TypeError(`Attribute '${key}' value must be either string or boolean in ${errorSuffix}`); - } - } - - // Make sure canonicalValues and referenceTypes are valid if they are specified - for (let [key, value] of safelyCollected) { - if (value !== undefined && value !== false && !Array.isArray(value)) { - throw new TypeError(`Attribute '${key}' value must be either a collection or 'false' in ${errorSuffix}`) - } - } - // Make sure attribute type is 'complex' if subAttributes are defined - if (subAttributes.length && type !== "complex") { + if (subAttributes.length && type !== "complex") throw new TypeError(`Attribute type must be 'complex' when subAttributes are specified in ${errorSuffix}`); - } + // Make sure subAttributes are all instances of Attribute + if (type === "complex" && !subAttributes.every(a => a instanceof Attribute)) + throw new TypeError(`Expected 'subAttributes' to be an array of Attribute instances in ${errorSuffix}`); // Attribute config is valid, proceed this.type = type; this.name = name; + // Prevent addition and removal of properties from config - // TODO: intercept values for validation - this.config = Object.seal({ - required: false, mutable: true, multiValued: false, caseExact: false, returned: true, - description: "", canonicalValues: false, referenceTypes: false, uniqueness: "none", direction: "both", - ...config - }); + this.config = Object.seal(Object + .assign(new Proxy({...BaseConfiguration.target}, BaseConfiguration.handler(errorSuffix)), config)); + // Store subAttributes if (type === "complex") this.subAttributes = [...subAttributes]; // Prevent this attribute definition from changing! @@ -347,6 +370,8 @@ export class Attribute { toJSON() { /** * @typedef {Object} SCIMMY.Types.Attribute~AttributeDefinition + * @alias AttributeDefinition + * @memberOf SCIMMY.Types.Attribute * @property {String} name - the attribute's name * @property {String} type - the attribute's data type * @property {String[]} [referenceTypes] - specifies a SCIM resourceType that a reference attribute may refer to From 6ac69477e593e57f0372445954ccd153f233cf4e Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 29 Nov 2021 14:18:12 +1100 Subject: [PATCH 30/93] Fix(SCIMMY.Types.Filter): don't split path in namespaces --- src/lib/types/filter.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index cb101cc..ec26206 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -24,6 +24,8 @@ const operators = ["and", "or", "not"]; const comparators = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr", "np"]; // Parsing Pattern Matcher const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|("(?:[^"]|\\.|\n)*")|(\[(?:.*?)\]|\((?:.*?)\))|(\w[-\w\._:\/%]*))/; +// Split a path by fullstops when they aren't in a filter group or decimal +const pathSeparator = /(? `${l[0].toLowerCase()}${l.slice(1)}`), + let literals = literal.split(pathSeparator).map(l => `${l[0].toLowerCase()}${l.slice(1)}`), target; // Peek at the next token to see if it's a comparator From 54a2b3249e1ac42c1d7c35eb7deedf8f13c6601b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 29 Nov 2021 14:19:41 +1100 Subject: [PATCH 31/93] Add(SCIMMY.Types.SchemaDefinition): filtering with namespaced attributes and extensions --- src/lib/types/definition.js | 48 +++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index 2d3ccaf..ad35736 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -283,8 +283,11 @@ export class SchemaDefinition { throw new TypeError(`Missing values for required schema extension '${name}'`); } else if (required || Object.keys(mixedSource).length) { try { - // Coerce the mixed value - target[name] = attribute.coerce(mixedSource, direction, basepath, filter); + // Coerce the mixed value, using only namespaced attributes for this extension + target[name] = attribute.coerce(mixedSource, direction, basepath, [Object.keys(filter ?? {}) + .filter(k => k.startsWith(`${name}:`)) + .reduce((res, key) => (((res[key.replace(`${name}:`, "")] = filter[key]) || true) && res), {}) + ]); } catch (ex) { // Rethrow exception with added context ex.message += ` in schema extension '${name}'`; @@ -332,25 +335,30 @@ export class SchemaDefinition { // Go through every value in the data and filter attributes for (let key in data) { - // TODO: namespaced attributes and extensions - // Get the matching attribute definition and some relevant config values - let attribute = attributes.find(a => a.name === key) ?? {}, - {type, config: {returned, multiValued} = {}, subAttributes} = attribute; - - // If the attribute is always returned, add it to the result - if (returned === "always") target[key] = data[key]; - // Otherwise, if the attribute ~can~ be returned, process it - else if (returned === true) { - // If the filter is simply based on presence, assign the result - if (Array.isArray(filter[key]) && filter[key][0] === "pr") + if (key.toLowerCase().startsWith("urn:")) { + // If there is data in a namespaced key, and a filter for it, include it + if (Object.keys(data[key]).length && Object.keys(filter).some(k => k.toLowerCase().startsWith(`${key.toLowerCase()}:`))) target[key] = data[key]; - // Otherwise if the filter is defined and the attribute is complex, evaluate it - else if (key in filter && type === "complex") { - let value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); - - // Only set the value if it isn't empty - if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length)) - target[key] = value; + } else { + // Get the matching attribute definition and some relevant config values + let attribute = attributes.find(a => a.name === key) ?? {}, + {type, config: {returned, multiValued} = {}, subAttributes} = attribute; + + // If the attribute is always returned, add it to the result + if (returned === "always") target[key] = data[key]; + // Otherwise, if the attribute ~can~ be returned, process it + else if (returned === true) { + // If the filter is simply based on presence, assign the result + if (Array.isArray(filter[key]) && filter[key][0] === "pr") + target[key] = data[key]; + // Otherwise if the filter is defined and the attribute is complex, evaluate it + else if (key in filter && type === "complex") { + let value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); + + // Only set the value if it isn't empty + if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length)) + target[key] = value; + } } } } From 6d7f7b5858c42289f8b604b439f199f1a7536b7e Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 29 Nov 2021 14:20:15 +1100 Subject: [PATCH 32/93] Tests(SCIMMY.Types.SchemaDefinition): filtering with namespaced attributes and extensions --- test/lib/types/definition.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 138f473..20c5717 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -393,6 +393,25 @@ export let SchemaDefinitionSuite = (SCIMMY) => { assert.ok(!Object.keys(result).includes("testValue"), "Instance method 'coerce' included attributes not specified for filter 'testName pr'"); }); + + it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { + let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ + new SCIMMY.Types.Attribute("string", "employeeNumber")]).extend(SCIMMY.Schemas.EnterpriseUser.definition), + result = definition.coerce( + { + employeeNumber: "Test", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", + }, + undefined, undefined, + new SCIMMY.Types.Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") + ); + + assert.strictEqual(result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].employeeNumber, "1234", + "Instance method 'coerce' did not include namespaced attributes for filter"); + assert.ok(!Object.keys(result).includes("testName"), + "Instance method 'coerce' included namespaced attributes not specified for filter"); + }); }); }); } \ No newline at end of file From a90667214a09e71ceeaf25545302cef018146af1 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 4 Dec 2021 15:53:29 +1100 Subject: [PATCH 33/93] Refactor(SCIMMY.Types.Filter): rewrite parser for improved versatility --- src/lib/types/filter.js | 229 ++++++++++++++++++++++++---------------- 1 file changed, 140 insertions(+), 89 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index ec26206..ea46dbd 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -23,9 +23,11 @@ const operators = ["and", "or", "not"]; */ const comparators = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr", "np"]; // Parsing Pattern Matcher -const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|("(?:[^"]|\\.|\n)*")|(\[(?:.*?)\]|\((?:.*?)\))|(\w[-\w\._:\/%]*))/; +const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|("(?:[^"]|\\.|\n)*")|(\((?:.*?)\))|(\[(?:.*?)\])|(\w[-\w\._:\/%]*))/; // Split a path by fullstops when they aren't in a filter group or decimal const pathSeparator = /(? 0) { - // Get the next token - let {value: literal, type} = tokens.shift(), - result = {}, - operator; - - // Handle group tokens - if (type === "Group" && Array.isArray(literal)) { - // Unwrap the group if it only contains one statement, otherwise wrap it - // TODO: what if the group is empty or contains empty statements? - results.push(literal.length === 1 ? literal.pop() ?? {} : {"&&": literal}); - } + return tokens; + } + + /** + * Divide a list of tokens into sets split by a given logical operator for parsing + * @param {Object[]} tokens - list of token objects in a query to divide by the given logical operation + * @param {String} operator - the logical operator to divide the tokens by + * @returns {Array} the supplied list of tokens split wherever the given operator occurred + */ + static #operations(tokens, operator) { + let operations = []; + + for (let token of [...tokens]) { + // Found the target operator token, push preceding tokens as an operation + if (token.type === "Operator" && token.value === operator) + operations.push(tokens.splice(0, tokens.indexOf(token) + 1).slice(0, -1)); + // Reached the end, add the remaining tokens as an operation + else if (tokens.indexOf(token) === tokens.length - 1) + operations.push(tokens.splice(0)); + } + + return operations; + } + + /** + * Translate a given set of expressions into their object representation + * @param {Array} expressions - list of expressions to combine into their object representation + * @returns {Object} translated representation of the given set of expressions + */ + static #objectify(expressions = []) { + let result = {}; + + // Go through every expression in the list, or handle a singular expression if that's what was given + for (let expression of (expressions.every(e => Array.isArray(e)) ? expressions : [expressions])) { + // Check if first token is negative for later evaluation + let negative = expression[0] === "not" ? expression.shift() : false, + // Extract expression parts and derive object path + [path, comparator, value] = expression, + parts = path.split(pathSeparator).filter(p => p), + target = result; - // Handle joining operators - if (type === "Operator") { - // Cache the current operator - operator = literal; - - // If operator is "and", get the last result to write the next statement to - if (operator === "and" && results.length > 0) result = results.pop(); + // Construct the object + for (let part of parts) { + // Check for filters in the path and fix the attribute name + let [, key = part, filter] = multiValuedFilter.exec(part) ?? [], + name = `${key[0].toLowerCase()}${key.slice(1)}`; - // If the next token is a "not" operator, handle negation of statement - if (tokens[0]?.type === "Operator" && tokens[0]?.value === "not") { - // Update the cached operator and put the result back on the stack - ({value: operator} = tokens.shift()); - results.push(result); - - // Continue evaluating the stack but on the special negative ("!!") property - result = result["!!"] = Array.isArray(tokens[0]?.value) ? [] : {}; + // If we have a nested filter, handle it + if (filter !== undefined) { + let values = Filter.#parse(filter.substring(1, filter.length - 1)); + if (values.length === 1) { + target[name] = Object.assign(target[name] ?? {}, values.pop()); + } else { + console.log(values); + } } - - // Move to the next token - ({value: literal, type} = tokens.shift()); - - // Poorly written filters sometimes unnecessarily include groups... - if (Array.isArray(literal)) { - // Put the result back on the stack (unless "not" already put it there) - if (operator !== "not") results.push(result); - // If the group only contains one statement, unwrap it - if (literal.length === 1) Object.assign(result, literal.pop() ?? {}); - // If the group follows a negation operator, add it to the negative ("!!") property - else if (operator === "not") result.splice(0, 0, ...literal); - // If a joining operator ("&&") group already exists here, add the new statements to it - else if (Array.isArray(result["&&"])) - result["&&"] = [...(!Array.isArray(result["&&"][0]) ? [result["&&"]] : result["&&"]), literal]; - // Otherwise, define a new joining operator ("&&") property with literal's statements in it - else result["&&"] = [literal]; + // If there's more path to follow, keep digging + else if (parts.indexOf(part) < parts.length - 1) { + target = (target[name] = target[name] ?? {}); + } + // Otherwise, we've reached our destination + else { + // Store the translated expression + target[name] = [negative, comparator, value].filter(v => v); } } + } + + return result; + } + + /** + * Parse a SCIM filter string into an array of objects representing the query filter + * @param {String|Object[]} [query=""] - the filter parameter of a request as per [RFC7644§3.4.2.2]{@link https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2} + * @returns {Object[]} parsed object representation of the queried filter + * @private + */ + static #parse(query = "") { + let tokens = (Array.isArray(query) ? query : Filter.#tokenise(query)), + results = []; + + // If there's no operators or groups, assume the expression is complete + if (!tokens.some(t => ["Operator", "Group"].includes(t.type))) { + results.push(Array.isArray(query) ? tokens.map(t => t.value) : Filter.#objectify(tokens.splice(0).map(t => t.value))); + } + // Otherwise, logic and groups need to be evaluated + else { + let expressions = []; - // Handle "words" in the filter (a.k.a. attributes) - if (type === "Word") { - // Put the result back on the stack if it's not already there - if (operator !== "not" && !Array.isArray(literal)) results.push(result); - - // Convert literal name into proper camelCase and expand into individual property names - let literals = literal.split(pathSeparator).map(l => `${l[0].toLowerCase()}${l.slice(1)}`), - target; + // Go through every "or" branch in the expression + for (let branch of Filter.#operations(tokens, "or")) { + // Find all "and" joins in the branch + let joins = Filter.#operations(branch, "and"), + // Find all complete expressions, and groups that need evaluating + expression = joins.filter(e => !e.some(t => t.type === "Group")), + groups = joins.filter(e => !expression.includes(e)); - // Peek at the next token to see if it's a comparator - if (tokens[0]?.type === "Comparator") { - // If so, get the comparator (the next token) - let {value: comparator} = tokens.shift(), - // If the comparator expects a value to compare against, get it - {value} = (!["pr", "np"].includes(comparator) ? tokens.shift() : {}); + // Evaluate the groups + for (let group of groups.splice(0)) { + // Check for negative and extract the group token + let [negate, token = negate] = group, + // Parse the group token, negating and stripping double negatives if necessary + tokens = Filter.#tokenise(token === negate ? token.value : `not ${token.value + .replaceAll(" and ", " and not ").replaceAll(" or ", " or not ") + .replaceAll(" and not not ", " and ").replaceAll(" or not not ", " or ")}`), + // Find all "or" branches in this group + branches = Filter.#operations(tokens, "or"); - // Save the comparator and value to the attribute - target = [comparator, ...(value !== undefined ? [value] : [])]; - - // Peek at the next token's value to see if the word opens a group - } else if (Array.isArray(tokens[0]?.value)) { - // If so, get the group, and collapse or store it in the result - let {value} = tokens.shift(); - target = (value.length === 1 ? value.pop() ?? {} : value); - } - - // Go through all nested attribute names - while (literals.length > 1) { - // TODO: what if there's a collision? - let key = literals.shift(); - result = (result[key] = result[key] ?? {}); + // Cross all existing groups with this branch + for (let group of (groups.length ? groups.splice(0) : [[]])) { + // Taking into consideration any complete expressions in the block + for (let token of (expression.length ? expression : [[]])) { + for (let branch of branches) { + groups.push([ + ...(token.length ? [token.map(t => t.value)] : []), + ...(group.length ? group : []), + ...Filter.#parse(branch) + ]); + } + } + } } - // Then assign the targeted value to the nested location - result[literals.shift()] = target; + // Consider each group its own expression + if (groups.length) expressions.push(...groups); + // Otherwise, collapse the expression for potential objectification + else expressions.push(expression.map(e => e.map(t => t.value))); + } + + // Push all expressions to results, objectifying if necessary + for (let expression of expressions) { + results.push(...(Array.isArray(query) ? expression : [Filter.#objectify(expression)])); } } From 0760cb9d0a6226471b9901a2554130bb6d42d82e Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 4 Dec 2021 15:57:37 +1100 Subject: [PATCH 34/93] Fix(SCIMMY.Types.Filter): don't strip grouping characters from start and end in tokenise --- src/lib/messages/patchop.js | 2 +- src/lib/types/filter.js | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index 7ba8f2d..e4ad053 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -206,7 +206,7 @@ export class PatchOp { if (target !== undefined) try { if (filter !== undefined) { // If a filter is specified, apply it to the target and add results back to targets - targets.push(...(new Types.Filter(filter).match(target[key]))); + targets.push(...(new Types.Filter(filter.substring(1, filter.length - 1)).match(target[key]))); } else { // Add the traversed value to targets, or back out if already arrived targets.push(paths.length === 0 ? target : target[key] ?? (op === "add" ? ((target[key] = target[key] ?? {}) && target[key]) : undefined)); diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index ea46dbd..f9b416e 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -106,10 +106,6 @@ export class Filter extends Array { let tokens = [], token; - // Strip grouping characters from start and end, if necessary - if ((query.startsWith("[") && query.endsWith("]")) || (query.startsWith("(") && query.endsWith(")"))) - query = query.substring(1, query.length - 1); - // Cycle through the query and tokenise it until it can't be tokenised anymore while (token = patterns.exec(query)) { // Extract the different matches from the token From e1303206b1c56893406e1fb1316cbd0e208b72e5 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 4 Dec 2021 15:58:36 +1100 Subject: [PATCH 35/93] Tests(SCIMMY.Types.Filter): update format of targets to match expected outputs --- test/lib/types/filter.json | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index f14f81e..fb4e6ed 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -9,9 +9,9 @@ "logical": [ {"source": "id pr and userName eq \"Test\"", "target": [{"id": ["pr"], "userName": ["eq", "Test"]}]}, {"source": "userName eq \"Test\" or displayName co \"Bob\"", "target": [{"userName": ["eq", "Test"]}, {"displayName": ["co", "Bob"]}]}, - {"source": "email.value ew \"@example.com\" and not userName eq \"Test\"", "target": [{"email": {"value": ["ew", "@example.com"]}, "!!": {"userName": ["eq", "Test"]}}]}, - {"source": "email.type eq \"work\" or not userName ne \"Test\"", "target": [{"email": {"type": ["eq", "work"]}}, {"!!": {"userName": ["ne", "Test"]}}]}, - {"source": "email.type eq \"work\" and not email.value ew \"@example.com\"", "target": [{"email": {"type": ["eq", "work"]}, "!!": {"email": {"value": ["ew", "@example.com"]}}}]}, + {"source": "email.value ew \"@example.com\" and not userName eq \"Test\"", "target": [{"email": {"value": ["ew", "@example.com"]}, "userName": ["not", "eq", "Test"]}]}, + {"source": "email.type eq \"work\" or not userName ne \"Test\"", "target": [{"email": {"type": ["eq", "work"]}}, {"userName": ["not", "ne", "Test"]}]}, + {"source": "email.type eq \"work\" and not email.value ew \"@example.com\"", "target": [{"email": {"type": ["eq", "work"], "value": ["not", "ew", "@example.com"]}}]}, {"source": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\"", "target": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}]} ], "grouping": [ @@ -21,24 +21,35 @@ }, { "source": "userType ne \"Employee\" and not (emails co \"example.com\" or emails.value co \"example.org\")", - "target": [{"userType": ["ne", "Employee"], "!!": [{"emails": ["co", "example.com"]}, {"emails": {"value": ["co", "example.org"]}}]}] + "target": [ + {"userType": ["ne", "Employee"], "emails": ["not", "co", "example.com"]}, + {"userType": ["ne", "Employee"], "emails": {"value": ["not", "co", "example.org"]}} + ] }, { "source": "emails[type eq \"work\" and value co \"@example.com\"] or ims[type eq \"xmpp\" and value co \"@foo.com\"]", - "target": [{"emails": {"type": ["eq", "work"], "value": ["co", "@example.com"]}}, {"ims": {"type": ["eq", "xmpp"], "value": ["co", "@foo.com"]}}] + "target": [ + {"emails": {"type": ["eq", "work"], "value": ["co", "@example.com"]}}, + {"ims": {"type": ["eq", "xmpp"], "value": ["co", "@foo.com"]}} + ] } ], "complex": [ { "source": "(name.FamilyName eq \"Employee\" or name.FamilyName eq \"Manager\") and (emails.Value co \"example.com\" or emails.Value co \"example.org\")", - "target": [{"&&": [ - [{"name": {"familyName": ["eq", "Employee"]}}, {"name": {"familyName": ["eq", "Manager"]}}], - [{"emails": {"value": ["co", "example.com"]}}, {"emails": {"value": ["co", "example.org"]}}] - ]}] + "target": [ + {"name": {"familyName": ["eq", "Employee"]}, "emails": {"value": ["co", "example.com"]}}, + {"name": {"familyName": ["eq", "Employee"]}, "emails": {"value": ["co", "example.org"]}}, + {"name": {"familyName": ["eq", "Manager"]}, "emails": {"value": ["co", "example.com"]}}, + {"name": {"familyName": ["eq", "Manager"]}, "emails": {"value": ["co", "example.org"]}} + ] }, { "source": "userType eq \"Employee\" and emails[type eq \"work\" or (primary eq true and value co \"@example.com\")]", - "target": [{"userType": ["eq", "Employee"], "emails": [{"type": ["eq", "work"]}, {"primary": ["eq", "true"], "value": ["co", "@example.com"]}]}] + "target": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", "true"], "value": ["co", "@example.com"]}} + ] } ] }, From a8aecb1cce444667fa08704d54dd959f43bc6901 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sun, 5 Dec 2021 21:02:56 +1100 Subject: [PATCH 36/93] Tests(SCIMMY.Types.Filter): add more fixtures to improve coverage --- test/lib/types/filter.json | 80 +++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index fb4e6ed..44026f0 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -4,7 +4,11 @@ {"source": "id pr", "target": [{"id": ["pr"]}]}, {"source": "userName eq \"Test\"", "target": [{"userName": ["eq", "Test"]}]}, {"source": "displayName co \"Bob\"", "target": [{"displayName": ["co", "Bob"]}]}, - {"source": "name.formatted sw \"Bob\"", "target": [{"name": {"formatted": ["sw", "Bob"]}}]} + {"source": "name.formatted sw \"Bob\"", "target": [{"name": {"formatted": ["sw", "Bob"]}}]}, + {"source": "quota gt 1.5", "target": [{"quota": ["gt", 1.5]}]}, + {"source": "UserType eq null", "target": [{"userType": ["eq", null]}]}, + {"source": "active eq false", "target": [{"active": ["eq", false]}]}, + {"source": "emails.primary eq true", "target": [{"emails": {"primary": ["eq", true]}}]} ], "logical": [ {"source": "id pr and userName eq \"Test\"", "target": [{"id": ["pr"], "userName": ["eq", "Test"]}]}, @@ -19,6 +23,17 @@ "source": "emails[type eq \"work\"]", "target": [{"emails": {"type": ["eq", "work"]}}] }, + { + "source": "emails[type eq \"work\"].value ew \"@example.org\"", + "target": [{"emails": {"type": ["eq", "work"], "value": ["ew", "@example.org"]}}] + }, + { + "source": "emails[type eq \"work\" or type eq \"home\"].value ew \"@example.org\"", + "target": [ + {"emails": {"type": ["eq", "work"], "value": ["ew", "@example.org"]}}, + {"emails": {"type": ["eq", "home"], "value": ["ew", "@example.org"]}} + ] + }, { "source": "userType ne \"Employee\" and not (emails co \"example.com\" or emails.value co \"example.org\")", "target": [ @@ -32,6 +47,38 @@ {"emails": {"type": ["eq", "work"], "value": ["co", "@example.com"]}}, {"ims": {"type": ["eq", "xmpp"], "value": ["co", "@foo.com"]}} ] + }, + { + "source": "emails[type eq \"work\" or type eq \"home\"].values[domain ew \"@example.org\"]", + "target": [ + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.org"]}}}, + {"emails": {"type": ["eq", "home"], "values": {"domain": ["ew", "@example.org"]}}} + ] + }, + { + "source": "emails[type eq \"work\" or type eq \"home\"].values[domain ew \"@example.org\" or domain ew \"@example.com\"]", + "target": [ + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.org"]}}}, + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.com"]}}}, + {"emails": {"type": ["eq", "home"], "values": {"domain": ["ew", "@example.org"]}}}, + {"emails": {"type": ["eq", "home"], "values": {"domain": ["ew", "@example.com"]}}} + ] + }, + { + "source": "emails[type eq \"work\" or type eq \"home\"].values[domain ew \"@example.org\" or domain ew \"@example.com\"].recipient pr", + "target": [ + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.org"], "recipient": ["pr"]}}}, + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.com"], "recipient": ["pr"]}}}, + {"emails": {"type": ["eq", "home"], "values": {"domain": ["ew", "@example.org"], "recipient": ["pr"]}}}, + {"emails": {"type": ["eq", "home"], "values": {"domain": ["ew", "@example.com"], "recipient": ["pr"]}}} + ] + }, + { + "source": "emails[type eq \"work\"].values[domain ew \"@example.org\" or domain ew \"@example.com\"].recipient pr", + "target": [ + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.org"], "recipient": ["pr"]}}}, + {"emails": {"type": ["eq", "work"], "values": {"domain": ["ew", "@example.com"], "recipient": ["pr"]}}} + ] } ], "complex": [ @@ -48,7 +95,36 @@ "source": "userType eq \"Employee\" and emails[type eq \"work\" or (primary eq true and value co \"@example.com\")]", "target": [ {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"]}}, - {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", "true"], "value": ["co", "@example.com"]}} + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"]}} + ] + }, + { + "source": "emails[type eq \"work\" or (primary eq true and value co \"@example.com\")] and userType eq \"Employee\"", + "target": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"]}} + ] + }, + { + "source": "userType eq \"Employee\" and emails[type eq \"work\" or (primary eq true and value co \"@example.com\")].display co \"Work\"", + "target": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, + { + "source": "userType eq \"Employee\" or emails[type eq \"work\" or (primary eq true and value co \"@example.com\")].display co \"Work\"", + "target": [ + {"userType": ["eq", "Employee"]}, + {"emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, + { + "source": "userType eq \"Employee\" or emails[type eq \"work\" and (primary eq false and value co \"@example.com\")].display co \"Work\"", + "target": [ + {"userType": ["eq", "Employee"]}, + {"emails": {"type": ["eq", "work"], "primary": ["eq", false], "value": ["co", "@example.com"], "display": ["co", "Work"]}} ] } ] From ce65d860ef46269cca9daf5a7f35099c0df88013 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sun, 5 Dec 2021 21:08:27 +1100 Subject: [PATCH 37/93] Add(SCIMMY.Types.Filter): handling of multi-value filters and groups in attribute names --- src/lib/types/filter.js | 156 +++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 42 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index f9b416e..324227d 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -23,7 +23,7 @@ const operators = ["and", "or", "not"]; */ const comparators = ["eq", "ne", "co", "sw", "ew", "gt", "lt", "ge", "le", "pr", "np"]; // Parsing Pattern Matcher -const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|("(?:[^"]|\\.|\n)*")|(\((?:.*?)\))|(\[(?:.*?)\])|(\w[-\w\._:\/%]*))/; +const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|(false|true)+|(null)+|("(?:[^"]|\\.|\n)*")|(\((?:.*?)\))|(\[(?:.*?)][.]?)|(\w[-\w._:\/%]*))/; // Split a path by fullstops when they aren't in a filter group or decimal const pathSeparator = /(? Array.isArray(e)) ? expressions : [expressions])) { // Check if first token is negative for later evaluation - let negative = expression[0] === "not" ? expression.shift() : false, + let negative = expression[0] === "not" ? expression.shift() : undefined, // Extract expression parts and derive object path [path, comparator, value] = expression, parts = path.split(pathSeparator).filter(p => p), target = result; // Construct the object - for (let part of parts) { - // Check for filters in the path and fix the attribute name - let [, key = part, filter] = multiValuedFilter.exec(part) ?? [], - name = `${key[0].toLowerCase()}${key.slice(1)}`; + for (let key of parts) { + // Fix the attribute name + let name = `${key[0].toLowerCase()}${key.slice(1)}`; - // If we have a nested filter, handle it - if (filter !== undefined) { - let values = Filter.#parse(filter.substring(1, filter.length - 1)); - if (values.length === 1) { - target[name] = Object.assign(target[name] ?? {}, values.pop()); - } else { - console.log(values); - } - } // If there's more path to follow, keep digging - else if (parts.indexOf(part) < parts.length - 1) { - target = (target[name] = target[name] ?? {}); - } + if (parts.indexOf(key) < parts.length - 1) target = (target[name] = target[name] ?? {}); // Otherwise, we've reached our destination else { - // Store the translated expression - target[name] = [negative, comparator, value].filter(v => v); + // Unwrap string and null values, and store the translated expression + value = (value === "null" ? null : (String(value).match(/^["].*["]$/) ? value.substring(1, value.length - 1) : value)); + target[name] = [negative, comparator, value].filter(v => v !== undefined); } } } @@ -226,11 +221,16 @@ export class Filter extends Array { */ static #parse(query = "") { let tokens = (Array.isArray(query) ? query : Filter.#tokenise(query)), + // Initial pass to check for complexities + simple = !tokens.some(t => ["Operator", "Group"].includes(t.type)), + // Closer inspection in case word tokens contain nested attribute filters + reallySimple = simple && (tokens[0]?.value ?? tokens[0] ?? "") + .split(pathSeparator).every(t => t === multiValuedFilter.exec(t).slice(1).shift()), results = []; - // If there's no operators or groups, assume the expression is complete - if (!tokens.some(t => ["Operator", "Group"].includes(t.type))) { - results.push(Array.isArray(query) ? tokens.map(t => t.value) : Filter.#objectify(tokens.splice(0).map(t => t.value))); + // If there's no operators or groups, and no nested attribute filters, assume the expression is complete + if (reallySimple) { + results.push(Array.isArray(query) ? tokens.map(t => t.value ?? t) : Filter.#objectify(tokens.splice(0).map(t => t.value ?? t))); } // Otherwise, logic and groups need to be evaluated else { @@ -244,6 +244,73 @@ export class Filter extends Array { expression = joins.filter(e => !e.some(t => t.type === "Group")), groups = joins.filter(e => !expression.includes(e)); + // Go through every expression and check for nested attribute filters + for (let e of expression.splice(0)) { + // Check if first token is negative for later evaluation + let negative = e[0].value === "not" ? e.shift() : undefined, + // Extract expression parts and derive object path + [path, comparator, value] = e; + + // If none of the path parts have multi-value filters, put the expression back on the stack + if (path.value.split(pathSeparator).filter(p => p).every(t => t === multiValuedFilter.exec(t).slice(1).shift())) { + expression.push([negative, path, comparator, value].filter(v => v !== undefined)); + } + // Otherwise, delve into the path parts for complexities + else { + let parts = path.value.split(pathSeparator).filter(p => p), + // Store results and spent path parts + results = [], + spent = []; + + for (let part of parts) { + // Check for filters in the path part + let [, key = part, filter] = multiValuedFilter.exec(part) ?? []; + + // Store the spent path part + spent.push(key); + + // If we have a nested filter, handle it + if (filter !== undefined) { + let branches = Filter + // Get any branches in the nested filter, parse them for joins, and properly wrap them + .#operations(Filter.#tokenise(filter.substring(1, filter.length - 1)), "or") + .map(b => Filter.#parse(b)) + .map(b => b.every(b => b.every(b => Array.isArray(b))) ? b.flat(1) : b) + // Prefix any attribute paths with spent parts + .map((branch) => branch.map(join => { + let negative = (join[0] === "not" ? join.shift() : undefined), + [path, comparator, value] = join; + + return [negative, `${spent.join(".")}.${path}`, comparator, value].filter(v => v !== undefined); + })); + + if (!results.length) { + // Extract results from the filter + results.push(...branches); + } else { + branches = branches.flat(1); + + // If only one branch, add it to existing results + if (branches.length === 1) for (let result of results) result.push(...branches); + // Otherwise, cross existing results with new branches + else for (let result of results.splice(0)) { + for (let branch of branches) results.push([...result, branch]); + } + } + } + // No filter, but if we're at the end of the chain, join the last expression with the results + else if (parts.indexOf(part) === parts.length - 1) { + for (let result of results) result.push([negative?.value, spent.join("."), comparator?.value, value?.value].filter(v => v !== undefined)); + } + } + + // If there's only one result, it wasn't a very complex expression + if (results.length === 1) expression.push(...results.pop()); + // Otherwise, turn the result back into a string and let groups handle it + else groups.push([{value: results.map(r => r.map(e => e.join(" ")).join(" and ")).join(" or ")}]); + } + } + // Evaluate the groups for (let group of groups.splice(0)) { // Check for negative and extract the group token @@ -255,16 +322,21 @@ export class Filter extends Array { // Find all "or" branches in this group branches = Filter.#operations(tokens, "or"); - // Cross all existing groups with this branch - for (let group of (groups.length ? groups.splice(0) : [[]])) { - // Taking into consideration any complete expressions in the block - for (let token of (expression.length ? expression : [[]])) { - for (let branch of branches) { - groups.push([ - ...(token.length ? [token.map(t => t.value)] : []), - ...(group.length ? group : []), - ...Filter.#parse(branch) - ]); + if (branches.length === 1) { + // No real branches, so it's probably a simple expression + expression.push(...Filter.#parse([...branches.pop()])); + } else { + // Cross all existing groups with this branch + for (let group of (groups.length ? groups.splice(0) : [[]])) { + // Taking into consideration any complete expressions in the block + for (let token of (expression.length ? expression : [[]])) { + for (let branch of branches) { + groups.push([ + ...(token.length ? [token.map(t => t.value ?? t)] : []), + ...(group.length ? group : []), + ...Filter.#parse(branch) + ]); + } } } } @@ -273,12 +345,12 @@ export class Filter extends Array { // Consider each group its own expression if (groups.length) expressions.push(...groups); // Otherwise, collapse the expression for potential objectification - else expressions.push(expression.map(e => e.map(t => t.value))); + else expressions.push(expression.map(e => e.map(t => t.value ?? t))); } // Push all expressions to results, objectifying if necessary for (let expression of expressions) { - results.push(...(Array.isArray(query) ? expression : [Filter.#objectify(expression)])); + results.push(...(Array.isArray(query) ? (expression.every(t => Array.isArray(t)) ? expression : [expression]) : [Filter.#objectify(expression)])); } } From bee3fada3f4b995057926efaa1f9218575b52051 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 6 Dec 2021 09:52:19 +1100 Subject: [PATCH 38/93] Chore(SCIMMY.Types.Filter): mark internal methods with private JSDoc tag --- src/lib/types/filter.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 324227d..fdbeead 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -98,9 +98,10 @@ export class Filter extends Array { } /** - * Extract a list of tokens representing the supplied expression + * Extract a list of tokens representing the supplied expression * @param {String} query - the expression to generate the token list for - * @returns {Object[]} a set of token objects representing the expression, with details on the token kinds + * @returns {Object[]} a set of token objects representing the expression, with details on the token kinds + * @private */ static #tokenise(query = "") { let tokens = [], @@ -158,9 +159,10 @@ export class Filter extends Array { /** * Divide a list of tokens into sets split by a given logical operator for parsing - * @param {Object[]} tokens - list of token objects in a query to divide by the given logical operation - * @param {String} operator - the logical operator to divide the tokens by - * @returns {Array} the supplied list of tokens split wherever the given operator occurred + * @param {Object[]} tokens - list of token objects in a query to divide by the given logical operation + * @param {String} operator - the logical operator to divide the tokens by + * @returns {Array} the supplied list of tokens split wherever the given operator occurred + * @private */ static #operations(tokens, operator) { let operations = []; @@ -181,6 +183,7 @@ export class Filter extends Array { * Translate a given set of expressions into their object representation * @param {Array} expressions - list of expressions to combine into their object representation * @returns {Object} translated representation of the given set of expressions + * @private */ static #objectify(expressions = []) { let result = {}; From d05e911b087aed6d9612ea831fa871bc0a5a25c7 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 2 Sep 2022 15:20:17 +1000 Subject: [PATCH 39/93] Fix(SCIMMY.Types.Filter): be case-insensitive and handle nested attribs in match method --- src/lib/types/filter.js | 65 ++++++++++++++++++++++++++++++-------- test/lib/types/filter.json | 6 +++- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index fdbeead..3c1374e 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -74,24 +74,63 @@ export class Filter extends Array { */ match(values) { // Match against any of the filters in the set - // TODO: finish comparators and handle nesting return values.filter(value => - this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, [comparator, expected]]) => { - // Cast true and false strings to boolean values - expected = (expected === "false" ? false : (expected === "true" ? true : expected)); + this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, expression]) => { + let [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? []; - switch (comparator) { - case "co": - return String(value[attr]).includes(expected); + if (!Array.isArray(expression)) { + return !!(new Filter([expression]).match([actual]).length); + } else { + let negate = (expression[0] === "not" ? !!expression.shift() : false), + [comparator, expected] = expression, + result; - case "pr": - return attr in value; + // Cast true and false strings to boolean values + expected = (expected === "false" ? false : (expected === "true" ? true : expected)); - case "eq": - return value[attr] === expected; + switch (comparator) { + case "eq": + result = (actual === expected); + break; + + case "ne": + result = (actual !== expected); + break; + + case "co": + result = String(actual).includes(expected); + break; + + case "sw": + result = String(actual).startsWith(expected); + break; + + case "ew": + result = String(actual).endsWith(expected); + break; + + case "gt": + result = (actual > expected); + break; + + case "lt": + result = (actual < expected); + break; + + case "ge": + result = (actual >= expected); + break; + + case "le": + result = (actual <= expected); + break; + + case "pr": + result = (attr in value); + break; + } - case "ne": - return value[attr] !== expected; + return (negate ? !result : result); } }))) ); diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 44026f0..bb527e7 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -137,7 +137,11 @@ {"id": 4, "userName": "MeganB", "name": {"formatted": "Megan Bowen"}, "date": "2021-09-08T23:02:28.986Z", "number": 9} ], "targets": [ - {"expression": {"userName": ["co", "A"]}, "expected": [1, 2]} + {"expression": {"userName": ["co", "A"]}, "expected": [1, 2]}, + {"expression": {"username": ["sw", "A"]}, "expected": [1]}, + {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]}, + {"expression": {"Name": {"fOrmaTTed": ["co", "a"]}}, "expected": [1, 2, 4]}, + {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]} ] } } \ No newline at end of file From 02c235516895d1563fa5d1428c9bbe9a8a9b0e80 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 14 Sep 2022 18:38:33 +1000 Subject: [PATCH 40/93] Add(SCIMMY.Types.Filter): handling of dates in match method --- src/lib/types/filter.js | 17 ++++++++++------- test/lib/types/filter.json | 9 ++++++++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 3c1374e..6ba015c 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -28,6 +28,8 @@ const patterns = /^(?:(\s+)|(-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?)|(false|true)+|(nu const pathSeparator = /(? this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, expression]) => { let [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? []; + const isActualDate = (actual instanceof Date || (new Date(actual).toString() !== "Invalid Date" && String(actual).match(isoDate))); if (!Array.isArray(expression)) { return !!(new Filter([expression]).match([actual]).length); } else { - let negate = (expression[0] === "not" ? !!expression.shift() : false), - [comparator, expected] = expression, + let negate = (expression[0] === "not"), + [comparator, expected] = expression.slice(((+negate) - expression.length)), result; // Cast true and false strings to boolean values @@ -110,23 +113,23 @@ export class Filter extends Array { break; case "gt": - result = (actual > expected); + result = (isActualDate ? (new Date(actual) > new Date(expected)) : actual > expected); break; case "lt": - result = (actual < expected); + result = (isActualDate ? (new Date(actual) < new Date(expected)) : actual < expected); break; case "ge": - result = (actual >= expected); + result = (isActualDate ? (new Date(actual) >= new Date(expected)) : actual >= expected); break; case "le": - result = (actual <= expected); + result = (isActualDate ? (new Date(actual) <= new Date(expected)) : actual <= expected); break; case "pr": - result = (attr in value); + result = actual !== undefined; break; } diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index bb527e7..920debc 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -141,7 +141,14 @@ {"expression": {"username": ["sw", "A"]}, "expected": [1]}, {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]}, {"expression": {"Name": {"fOrmaTTed": ["co", "a"]}}, "expected": [1, 2, 4]}, - {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]} + {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]}, + {"expression": {"date": ["gt", "2021-08-05"]}, "expected": [2, 3, 4]}, + {"expression": {"date": ["lt", "2021-09"]}, "expected": [1, 3]}, + {"expression": {"date": ["co", "2021-09"]}, "expected": [2, 4]}, + {"expression": {"date": ["not", "co", "2021-09"]}, "expected": [1, 3]}, + {"expression": {"date": ["gt", "2021-08-05T12:00:00Z"]}, "expected": [2, 4]}, + {"expression": {"date": ["ge", "2021-09-08T23:02:28.986Z"]}, "expected": [2, 4]}, + {"expression": {"date": ["le", "2021-09-08T23:02:28.986Z"]}, "expected": [1, 3, 4]} ] } } \ No newline at end of file From ad0c797775c7ea7e1301cb8c0c8d16dd7eab8d9b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 15 Sep 2022 18:31:25 +1000 Subject: [PATCH 41/93] Add(SCIMMY.Types.Filter): handling of multi-valued attributes in match method --- src/lib/types/filter.js | 12 +++-- test/lib/types/filter.js | 27 +++++++--- test/lib/types/filter.json | 104 ++++++++++++++++++++++++++++++------- 3 files changed, 112 insertions(+), 31 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 6ba015c..8903408 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -81,7 +81,9 @@ export class Filter extends Array { let [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? []; const isActualDate = (actual instanceof Date || (new Date(actual).toString() !== "Invalid Date" && String(actual).match(isoDate))); - if (!Array.isArray(expression)) { + if (Array.isArray(actual)) { + return !!(new Filter(expression).match(actual).length); + } else if (!Array.isArray(expression)) { return !!(new Filter([expression]).match([actual]).length); } else { let negate = (expression[0] === "not"), @@ -113,19 +115,19 @@ export class Filter extends Array { break; case "gt": - result = (isActualDate ? (new Date(actual) > new Date(expected)) : actual > expected); + result = (isActualDate ? (new Date(actual) > new Date(expected)) : (typeof actual === typeof expected && actual > expected)); break; case "lt": - result = (isActualDate ? (new Date(actual) < new Date(expected)) : actual < expected); + result = (isActualDate ? (new Date(actual) < new Date(expected)) : (typeof actual === typeof expected && actual < expected)); break; case "ge": - result = (isActualDate ? (new Date(actual) >= new Date(expected)) : actual >= expected); + result = (isActualDate ? (new Date(actual) >= new Date(expected)) : (typeof actual === typeof expected && actual >= expected)); break; case "le": - result = (isActualDate ? (new Date(actual) <= new Date(expected)) : actual <= expected); + result = (isActualDate ? (new Date(actual) <= new Date(expected)) : (typeof actual === typeof expected && actual <= expected)); break; case "pr": diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 9783704..08ea5a7 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -107,14 +107,25 @@ export let FilterSuite = (SCIMMY) => { "Instance method 'match' not defined"); }); - it("should match values for a given filter expression", async () => { - let {match: {source, targets: suite}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual(new SCIMMY.Types.Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, - `Filter type class failed to match expression fixture #${suite.indexOf(fixture)+1}`); - } - }); + const targets = [ + ["comparators", "handle matches for known comparison expressions"], + ["nesting", "handle matching of nested attributes"], + ["cases", "match attribute names in a case-insensitive manner"], + ["negations", "handle matches with negation expressions"], + ["numbers", "correctly compare numeric attribute values"], + ["dates", "correctly compare ISO 8601 datetime string attribute values"] + ]; + + for (let [key, label] of targets) { + it(`should ${label}`, async () => { + let {match: {source, targets: {[key]: suite}}} = await fixtures; + + for (let fixture of suite) { + assert.deepStrictEqual(new SCIMMY.Types.Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, + `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1} with expression '${JSON.stringify(fixture.expression)}'`); + } + }); + } }); }); } \ No newline at end of file diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 920debc..45e1e84 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -131,24 +131,92 @@ }, "match": { "source": [ - {"id": 1, "userName": "AdeleV", "name": {"formatted": "Adele Vance"}, "date": "2021-07-25T12:37:58.132Z", "number": 4}, - {"id": 2, "userName": "GradyA", "name": {"formatted": "Grady Archie"}, "date": "2021-09-22T02:32:12.026Z", "number": 6}, - {"id": 3, "userName": "LynneR", "name": {"formatted": "Lynne Robbins"}, "date": "2021-08-05T10:11:57.910Z", "number": 14}, - {"id": 4, "userName": "MeganB", "name": {"formatted": "Megan Bowen"}, "date": "2021-09-08T23:02:28.986Z", "number": 9} + { + "id": 1, "userName": "AdeleV", "date": "2021-07-25T12:37:58.132Z", "number": 4, "exists": true, + "name": {"formatted": "Adele Vance", "givenName": "Adele", "familyName": "Vance"}, + "emails": [ + {"type": "work", "value": "AdeleV@example.net", "primary": true} + ] + }, + { + "id": 2, "userName": "GradyA", "date": "2021-09-22T02:32:12.026Z", "number": 6, + "name": {"formatted": "Grady Archie", "givenName": "Grady", "familyName": "Archie"}, + "emails": [ + {"type": "home", "value": "GradyA@example.com", "primary": true} + ] + }, + { + "id": 3, "userName": "LynneR", "date": "2021-08-05T10:11:57.910Z", "number": 14, "exists": false, + "name": {"formatted": "Lynne Robbins", "givenName": "Lynne", "familyName": "Robbins"}, + "emails": [ + {"type": "work", "value": "LynneR@example.org", "primary": true} + ] + }, + { + "id": 4, "userName": "MeganB", "date": "2021-09-08T23:02:28.986Z", "number": 9, + "name": {"formatted": "Megan Bowen", "givenName": "Megan", "familyName": "Bowen"}, + "emails": [ + {"type": "work", "value": "MeganB@example.org", "primary": true}, + {"type": "home", "value": "MeganB@example.net", "primary": false} + ] + } ], - "targets": [ - {"expression": {"userName": ["co", "A"]}, "expected": [1, 2]}, - {"expression": {"username": ["sw", "A"]}, "expected": [1]}, - {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]}, - {"expression": {"Name": {"fOrmaTTed": ["co", "a"]}}, "expected": [1, 2, 4]}, - {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]}, - {"expression": {"date": ["gt", "2021-08-05"]}, "expected": [2, 3, 4]}, - {"expression": {"date": ["lt", "2021-09"]}, "expected": [1, 3]}, - {"expression": {"date": ["co", "2021-09"]}, "expected": [2, 4]}, - {"expression": {"date": ["not", "co", "2021-09"]}, "expected": [1, 3]}, - {"expression": {"date": ["gt", "2021-08-05T12:00:00Z"]}, "expected": [2, 4]}, - {"expression": {"date": ["ge", "2021-09-08T23:02:28.986Z"]}, "expected": [2, 4]}, - {"expression": {"date": ["le", "2021-09-08T23:02:28.986Z"]}, "expected": [1, 3, 4]} - ] + "targets": { + "comparators": [ + {"expression": {"userName": ["eq", "AdeleV"]}, "expected": [1]}, + {"expression": {"userName": ["ne", "AdeleV"]}, "expected": [2, 3, 4]}, + {"expression": {"userName": ["co", "A"]}, "expected": [1, 2]}, + {"expression": {"userName": ["sw", "A"]}, "expected": [1]}, + {"expression": {"userName": ["ew", "A"]}, "expected": [2]}, + {"expression": {"number": ["gt", 9]}, "expected": [3]}, + {"expression": {"number": ["ge", 9]}, "expected": [3, 4]}, + {"expression": {"number": ["lt", 6]}, "expected": [1]}, + {"expression": {"number": ["le", 6]}, "expected": [1, 2]}, + {"expression": {"userName": ["pr"]}, "expected": [1, 2, 3, 4]}, + {"expression": {"exists": ["pr"]}, "expected": [1, 3]} + ], + "nesting": [ + {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]}, + {"expression": {"name": {"formatted": ["ew", "e"]}}, "expected": [1, 2]}, + {"expression": {"userName": ["co", "A"], "name": {"formatted": ["co", "a"]}}, "expected": [1, 2]}, + {"expression": {"emails": {"type": ["eq", "work"]}}, "expected": [1, 3, 4]}, + {"expression": {"emails": {"value": ["ew", "example.net"]}}, "expected": [1, 4]}, + {"expression": {"emails": {"type": ["eq", "work"], "value": ["ew", "example.net"]}}, "expected": [1]} + ], + "cases": [ + {"expression": {"username": ["sw", "A"]}, "expected": [1]}, + {"expression": {"Name": {"fOrmaTTed": ["co", "a"]}}, "expected": [1, 2, 4]}, + {"expression": {"Name": {"fOrmaTTed": ["co", "a"]}, "emaILs": {"VALUe": ["ew", "example.net"]}}, "expected": [1, 4]} + ], + "numbers": [ + {"expression": {"number": ["eq", 6]}, "expected": [2]}, + {"expression": {"number": ["eq", "6"]}, "expected": []}, + {"expression": {"number": ["ne", 6]}, "expected": [1, 3, 4]}, + {"expression": {"number": ["ne", "6"]}, "expected": [1, 2, 3, 4]}, + {"expression": {"number": ["gt", 9]}, "expected": [3]}, + {"expression": {"number": ["gt", "9"]}, "expected": []}, + {"expression": {"number": ["ge", 9]}, "expected": [3, 4]}, + {"expression": {"number": ["ge", "9"]}, "expected": []}, + {"expression": {"number": ["lt", 6]}, "expected": [1]}, + {"expression": {"number": ["lt", "6"]}, "expected": []}, + {"expression": {"number": ["le", 6]}, "expected": [1, 2]}, + {"expression": {"number": ["le", "6"]}, "expected": []} + ], + "dates": [ + {"expression": {"date": ["gt", "2021-08-05"]}, "expected": [2, 3, 4]}, + {"expression": {"date": ["lt", "2021-09"]}, "expected": [1, 3]}, + {"expression": {"date": ["co", "2021-09"]}, "expected": [2, 4]}, + {"expression": {"date": ["gt", "2021-08-05T12:00:00Z"]}, "expected": [2, 4]}, + {"expression": {"date": ["ge", "2021-09-08T23:02:28.986Z"]}, "expected": [2, 4]}, + {"expression": {"date": ["le", "2021-09-08T23:02:28.986Z"]}, "expected": [1, 3, 4]} + ], + "negations": [ + {"expression": {"userName": ["not", "pr"]}, "expected": []}, + {"expression": {"userName": ["not", "sw", "A"]}, "expected": [2, 3, 4]}, + {"expression": {"exists": ["not", "pr"]}, "expected": [2, 4]}, + {"expression": {"date": ["not", "co", "2021-09"]}, "expected": [1, 3]}, + {"expression": {"date": ["not", "le", "2021-09-08T23:02:28.986Z"]}, "expected": [2]} + ] + } } } \ No newline at end of file From 7d4841d25aca0ae2be49910c75573de1e9d3e911 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 16 Sep 2022 18:58:38 +1000 Subject: [PATCH 42/93] Add(SCIMMY.Types.Filter): handling of logical 'and' operations targeting same attribute --- src/lib/types/filter.js | 119 ++++++++++++++++++++++--------------- test/lib/types/filter.js | 4 +- test/lib/types/filter.json | 18 +++++- 3 files changed, 90 insertions(+), 51 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 8903408..ed5c1ef 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -77,65 +77,85 @@ export class Filter extends Array { match(values) { // Match against any of the filters in the set return values.filter(value => - this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, expression]) => { + this.some(f => (f !== Object(f) ? false : Object.entries(f).every(([attr, expressions]) => { let [,actual] = Object.entries(value).find(([key]) => key.toLowerCase() === attr.toLowerCase()) ?? []; const isActualDate = (actual instanceof Date || (new Date(actual).toString() !== "Invalid Date" && String(actual).match(isoDate))); if (Array.isArray(actual)) { - return !!(new Filter(expression).match(actual).length); - } else if (!Array.isArray(expression)) { - return !!(new Filter([expression]).match([actual]).length); + // Handle multivalued attributes by diving into them + return !!(new Filter(expressions).match(actual).length); + } else if (!Array.isArray(expressions)) { + // Handle complex attributes by diving into them + return !!(new Filter([expressions]).match([actual]).length); } else { - let negate = (expression[0] === "not"), - [comparator, expected] = expression.slice(((+negate) - expression.length)), - result; + let result = null; - // Cast true and false strings to boolean values - expected = (expected === "false" ? false : (expected === "true" ? true : expected)); - - switch (comparator) { - case "eq": - result = (actual === expected); - break; - - case "ne": - result = (actual !== expected); - break; - - case "co": - result = String(actual).includes(expected); - break; - - case "sw": - result = String(actual).startsWith(expected); - break; - - case "ew": - result = String(actual).endsWith(expected); - break; + // Go through the list of expressions for the attribute to see if the value matches + for (let expression of (expressions.every(Array.isArray) ? expressions : [expressions])) { + // Bail out if the value didn't match the last expression + if (result === false) break; - case "gt": - result = (isActualDate ? (new Date(actual) > new Date(expected)) : (typeof actual === typeof expected && actual > expected)); - break; + // Check for negation and extract the comparator and expected values + const negate = (expression[0] === "not"); + let [comparator, expected] = expression.slice(((+negate) - expression.length)); - case "lt": - result = (isActualDate ? (new Date(actual) < new Date(expected)) : (typeof actual === typeof expected && actual < expected)); - break; + // Cast true and false strings to boolean values + expected = (expected === "false" ? false : (expected === "true" ? true : expected)); - case "ge": - result = (isActualDate ? (new Date(actual) >= new Date(expected)) : (typeof actual === typeof expected && actual >= expected)); - break; - - case "le": - result = (isActualDate ? (new Date(actual) <= new Date(expected)) : (typeof actual === typeof expected && actual <= expected)); - break; + switch (comparator) { + default: + result = false; + break; + + case "eq": + result = (actual === expected); + break; + + case "ne": + result = (actual !== expected); + break; + + case "co": + result = String(actual).includes(expected); + break; + + case "sw": + result = String(actual).startsWith(expected); + break; + + case "ew": + result = String(actual).endsWith(expected); + break; + + case "gt": + result = (isActualDate ? (new Date(actual) > new Date(expected)) : (typeof actual === typeof expected && actual > expected)); + break; + + case "lt": + result = (isActualDate ? (new Date(actual) < new Date(expected)) : (typeof actual === typeof expected && actual < expected)); + break; + + case "ge": + result = (isActualDate ? (new Date(actual) >= new Date(expected)) : (typeof actual === typeof expected && actual >= expected)); + break; + + case "le": + result = (isActualDate ? (new Date(actual) <= new Date(expected)) : (typeof actual === typeof expected && actual <= expected)); + break; + + case "pr": + result = actual !== undefined; + break; + + case "np": + result = actual === undefined; + break; + } - case "pr": - result = actual !== undefined; - break; + result = (negate ? !result : result); } - return (negate ? !result : result); + return result; } }))) ); @@ -252,7 +272,10 @@ export class Filter extends Array { else { // Unwrap string and null values, and store the translated expression value = (value === "null" ? null : (String(value).match(/^["].*["]$/) ? value.substring(1, value.length - 1) : value)); - target[name] = [negative, comparator, value].filter(v => v !== undefined); + const expression = [negative, comparator, value].filter(v => v !== undefined); + + // Either store the single expression, or convert to array if attribute already has an expression defined + target[name] = (!Array.isArray(target[name]) ? expression : [...(target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]); } } } diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 08ea5a7..4f5ff45 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -113,7 +113,9 @@ export let FilterSuite = (SCIMMY) => { ["cases", "match attribute names in a case-insensitive manner"], ["negations", "handle matches with negation expressions"], ["numbers", "correctly compare numeric attribute values"], - ["dates", "correctly compare ISO 8601 datetime string attribute values"] + ["dates", "correctly compare ISO 8601 datetime string attribute values"], + ["logicalAnd", "match values against all expressions in a group of logical 'and' expressions for a single attribute"], + ["logicalOr", "match values against any one expression in a group of logical 'or' expressions"] ]; for (let [key, label] of targets) { diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 45e1e84..37dcb24 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -16,7 +16,9 @@ {"source": "email.value ew \"@example.com\" and not userName eq \"Test\"", "target": [{"email": {"value": ["ew", "@example.com"]}, "userName": ["not", "eq", "Test"]}]}, {"source": "email.type eq \"work\" or not userName ne \"Test\"", "target": [{"email": {"type": ["eq", "work"]}}, {"userName": ["not", "ne", "Test"]}]}, {"source": "email.type eq \"work\" and not email.value ew \"@example.com\"", "target": [{"email": {"type": ["eq", "work"], "value": ["not", "ew", "@example.com"]}}]}, - {"source": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\"", "target": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}]} + {"source": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\"", "target": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}]}, + {"source": "quota gt 1.5 and quota lt 2", "target": [{"quota": [["gt", 1.5], ["lt", 2]]}]}, + {"source": "userName sw \"A\" and userName ew \"Z\" and userName co \"m\"", "target": [{"userName": [["sw", "A"], ["ew", "Z"], ["co", "m"]]}]} ], "grouping": [ { @@ -173,7 +175,9 @@ {"expression": {"number": ["lt", 6]}, "expected": [1]}, {"expression": {"number": ["le", 6]}, "expected": [1, 2]}, {"expression": {"userName": ["pr"]}, "expected": [1, 2, 3, 4]}, - {"expression": {"exists": ["pr"]}, "expected": [1, 3]} + {"expression": {"exists": ["pr"]}, "expected": [1, 3]}, + {"expression": {"userName": ["np"]}, "expected": []}, + {"expression": {"exists": ["np"]}, "expected": [2, 4]} ], "nesting": [ {"expression": {"name": {"formatted": ["co", "a"]}}, "expected": [1, 2, 4]}, @@ -216,6 +220,16 @@ {"expression": {"exists": ["not", "pr"]}, "expected": [2, 4]}, {"expression": {"date": ["not", "co", "2021-09"]}, "expected": [1, 3]}, {"expression": {"date": ["not", "le", "2021-09-08T23:02:28.986Z"]}, "expected": [2]} + ], + "logicalAnd": [ + {"expression": {"userName": [["co", "e"], ["ew", "V"]]}, "expected": [1]}, + {"expression": {"userName": [["eq", "GradyA"], ["co", "e"], ["ew", "V"]]}, "expected": []}, + {"expression": {"number": [["ge", 6], ["le", 9]]}, "expected": [2, 4]}, + {"expression": {"date": [["ge", "2021-09"], ["lt", "2021-09-20"]]}, "expected": [4]} + ], + "logicalOr": [ + {"expression": [{"name": {"formatted": ["co", "an"]}}, {"userName": ["co", "ad"]}], "expected": [1, 2, 4]}, + {"expression": [{"date": ["gt", "2021-09-08"]}, {"userName": ["co", "a"]}], "expected": [2, 4]} ] } } From 3cc442e01f21ffdbdf6ccb7f60b84ad7dadf4435 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 30 Mar 2023 15:40:10 +1100 Subject: [PATCH 43/93] Chore(SCIMMY.Messages): fix grammar and titles in documentation --- src/lib/messages/bulkrequest.js | 4 ++-- src/lib/messages/bulkresponse.js | 4 ++-- src/lib/messages/error.js | 2 +- src/lib/messages/listresponse.js | 2 +- src/lib/messages/patchop.js | 6 +++--- src/lib/messages/searchrequest.js | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js index 90be633..39a9814 100644 --- a/src/lib/messages/bulkrequest.js +++ b/src/lib/messages/bulkrequest.js @@ -16,7 +16,7 @@ import Resources from "../resources.js"; const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; /** - * SCIM Bulk Request Message Type + * SCIM Bulk Request Message * @alias SCIMMY.Messages.BulkRequest * @since 1.0.0 * @summary @@ -32,7 +32,7 @@ export class BulkRequest { static #id = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; /** - * Whether or not the incoming BulkRequest has been applied + * Whether the incoming BulkRequest has been applied * @type {Boolean} * @private */ diff --git a/src/lib/messages/bulkresponse.js b/src/lib/messages/bulkresponse.js index a7b98e2..30a37ca 100644 --- a/src/lib/messages/bulkresponse.js +++ b/src/lib/messages/bulkresponse.js @@ -1,5 +1,5 @@ /** - * SCIM Bulk Response Message Type + * SCIM Bulk Response Message * @alias SCIMMY.Messages.BulkResponse * @since 1.0.0 * @summary @@ -32,7 +32,7 @@ export class BulkResponse { if (!outbound && !operations.length) throw new TypeError("BulkResponse request body must contain 'Operations' attribute with at least one operation"); - // All seems ok, prepare the BulkResponse + // All seems OK, prepare the BulkResponse this.schemas = [BulkResponse.#id]; this.Operations = [...operations]; } diff --git a/src/lib/messages/error.js b/src/lib/messages/error.js index 6482954..0b897e6 100644 --- a/src/lib/messages/error.js +++ b/src/lib/messages/error.js @@ -31,7 +31,7 @@ const validScimTypes = [ const validCodeTypes = {400: validScimTypes.slice(2), 409: ["uniqueness"], 413: ["tooMany"]}; /** - * SCIM Error Message Type + * SCIM Error Message * @alias SCIMMY.Messages.Error * @summary * * Formats exceptions to conform to the [HTTP Status and Error Response Handling](https://datatracker.ietf.org/doc/html/rfc7644#section-3.12) section of the SCIM protocol, ensuring HTTP status codes and scimType error detail keyword pairs are valid. diff --git a/src/lib/messages/listresponse.js b/src/lib/messages/listresponse.js index 84a2490..196dba1 100644 --- a/src/lib/messages/listresponse.js +++ b/src/lib/messages/listresponse.js @@ -1,5 +1,5 @@ /** - * SCIM List Response Message Type + * SCIM List Response Message * @alias SCIMMY.Messages.ListResponse * @summary * * Formats supplied service provider resources as [ListResponse messages](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2), handling pagination and sort when required. diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index e4ad053..afa6440 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -18,7 +18,7 @@ const pathSeparator = /(? Date: Mon, 3 Apr 2023 17:21:35 +1000 Subject: [PATCH 44/93] Chore(SCIMMY.Types): fix grammar and titles in documentation --- src/lib/types/attribute.js | 12 ++++++------ src/lib/types/definition.js | 4 ++-- src/lib/types/error.js | 2 +- src/lib/types/resource.js | 6 +++--- src/lib/types/schema.js | 4 ++-- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index 7375a84..f622c30 100644 --- a/src/lib/types/attribute.js +++ b/src/lib/types/attribute.js @@ -10,7 +10,7 @@ const BaseConfiguration = { * @property {String} [description=""] - a human-readable description of the attribute * @property {Boolean} [required=false] - whether the attribute is required for the type instance to be valid * @property {Boolean|String[]} [canonicalValues=false] - values the attribute's contents must be set to - * @property {Boolean} [caseExact=false] - whether the attribute's contents is case sensitive + * @property {Boolean} [caseExact=false] - whether the attribute's contents is case-sensitive * @property {Boolean|String} [mutable=true] - whether the attribute's contents is modifiable * @property {Boolean|String} [returned=true] - whether the attribute is returned in a response * @property {Boolean|String[]} [referenceTypes=false] - list of referenced types if attribute type is reference @@ -46,7 +46,7 @@ const BaseConfiguration = { else if (value !== undefined && !["string", "boolean"].includes(typeof value)) throw new TypeError(`Attribute '${label}' value must be either string or boolean in ${errorSuffix}`); } - + // Set the value! return (target[key] = value) || true; } @@ -287,7 +287,7 @@ const validate = { } /** - * SCIM Attribute + * SCIM Attribute Type * @alias SCIMMY.Types.Attribute * @summary * * Defines a SCIM schema attribute, and is used to ensure a given resource's value conforms to the attribute definition. @@ -377,11 +377,11 @@ export class Attribute { * @property {String[]} [referenceTypes] - specifies a SCIM resourceType that a reference attribute may refer to * @property {Boolean} multiValued - boolean value indicating an attribute's plurality * @property {String} description - a human-readable description of the attribute - * @property {Boolean} required - boolean value indicating whether or not the attribute is required + * @property {Boolean} required - boolean value indicating whether the attribute is required * @property {SCIMMY.Types.Attribute~AttributeDefinition[]} [subAttributes] - defines the sub-attributes of a complex attribute - * @property {Boolean} [caseExact] - boolean value indicating whether or not a string attribute is case sensitive + * @property {Boolean} [caseExact] - boolean value indicating whether a string attribute is case-sensitive * @property {String[]} [canonicalValues] - collection of canonical values - * @property {String} mutability - indicates whether or not an attribute is modifiable + * @property {String} mutability - indicates whether an attribute is modifiable * @property {String} returned - indicates when an attribute is returned in a response * @property {String} [uniqueness] - indicates how unique a value must be */ diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index ad35736..938f807 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -2,7 +2,7 @@ import {Attribute} from "./attribute.js"; import {Filter} from "./filter.js"; /** - * SCIM Schema Definition + * SCIM Schema Definition Type * @alias SCIMMY.Types.SchemaDefinition * @summary * * Defines an underlying SCIM schema definition, containing the schema's URN namespace, friendly name, description, and collection of attributes that make up the schema. @@ -126,7 +126,7 @@ export class SchemaDefinition { /** * Extend a schema definition instance by mixing in other schemas or attributes * @param {SCIMMY.Types.SchemaDefinition|Array} extension - the schema extension or collection of attributes to register - * @param {Boolean} [required=false] - if the extension is a schema, whether or not the extension is required + * @param {Boolean} [required=false] - if the extension is a schema, whether the extension is required * @returns {SCIMMY.Types.SchemaDefinition} this schema definition instance for chaining */ extend(extension = [], required) { diff --git a/src/lib/types/error.js b/src/lib/types/error.js index 242edf5..18addcc 100644 --- a/src/lib/types/error.js +++ b/src/lib/types/error.js @@ -1,5 +1,5 @@ /** - * SCIM Error + * SCIM Error Type * @alias SCIMMY.Types.Error * @see SCIMMY.Messages.Error * @summary diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 12e33b0..497cfe8 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -3,7 +3,7 @@ import {Schema} from "./schema.js"; import {Filter} from "./filter.js"; /** - * SCIM Resource + * SCIM Resource Type * @alias SCIMMY.Types.Resource * @summary * * Extendable class representing a SCIM Resource Type, which acts as an interface between a SCIM resource type schema, and an app's internal data model. @@ -64,7 +64,7 @@ export class Resource { /** * Register an extension to the resource's core schema * @param {SCIMMY.Types.Schema} extension - the schema extension to register - * @param {Boolean} required - whether or not the extension is required + * @param {Boolean} required - whether the extension is required * @returns {SCIMMY.Types.Resource|void} this resource type implementation for chaining */ static extend(extension, required) { @@ -147,7 +147,7 @@ export class Resource { * @property {String} description - human-readable description of the resource * @property {Object} [schemaExtensions] - schema extensions that augment the resource * @property {String} schemaExtensions[].schema - URN namespace of the schema extension that augments the resource - * @property {Boolean} schemaExtensions[].required - whether or not resource instances must include the schema extension + * @property {Boolean} schemaExtensions[].required - whether resource instances must include the schema extension */ return { id: this.schema.definition.name, name: this.schema.definition.name, endpoint: this.endpoint, diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js index 93dea92..cff0df1 100644 --- a/src/lib/types/schema.js +++ b/src/lib/types/schema.js @@ -3,7 +3,7 @@ import {Attribute} from "./attribute.js"; import {SCIMError} from "./error.js"; /** - * SCIM Schema + * SCIM Schema Type * @alias SCIMMY.Types.Schema * @summary * * Extendable class which provides the ability to construct resource instances with automated validation of conformity to a resource's schema definition. @@ -30,7 +30,7 @@ export class Schema { /** * Extend a schema by mixing in other schemas or attributes * @param {SCIMMY.Types.Schema|Array} extension - the schema extensions or collection of attributes to register - * @param {Boolean} [required=false] - if the extension is a schema, whether or not the extension is required + * @param {Boolean} [required=false] - if the extension is a schema, whether the extension is required */ static extend(extension, required = false) { this.definition.extend((extension.prototype instanceof Schema ? extension.definition : extension), required); From f2c90dec3fc2c568755e279198d36c19b1831ad1 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 3 Apr 2023 17:24:04 +1000 Subject: [PATCH 45/93] Chore(SCIMMY.{Config,Resources,Schemas}): fix titles in documentation --- src/lib/config.js | 2 +- src/lib/resources.js | 2 +- src/lib/schemas.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/config.js b/src/lib/config.js index 9ac829c..7a37572 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -5,7 +5,7 @@ const catchAll = () => {throw new TypeError("SCIM Configuration can only be chan const handleTraps = {set: catchAll, deleteProperty: catchAll, defineProperty: catchAll}; /** - * SCIM Service Provider Configuration Container Class + * SCIMMY Service Provider Configuration Class * @namespace SCIMMY.Config * @description * SCIMMY provides a singleton class, `SCIMMY.Config`, that acts as a central store for a SCIM Service Provider's configuration. diff --git a/src/lib/resources.js b/src/lib/resources.js index bf45ab1..61e773e 100644 --- a/src/lib/resources.js +++ b/src/lib/resources.js @@ -7,7 +7,7 @@ import Types from "./types.js"; import Schemas from "./schemas.js"; /** - * SCIM Resources Container Class + * SCIMMY Resources Container Class * @namespace SCIMMY.Resources * @description * SCIMMY provides a singleton class, `SCIMMY.Resources`, that is used to declare resource types implemented by a SCIM Service Provider. diff --git a/src/lib/schemas.js b/src/lib/schemas.js index f508aca..0a286eb 100644 --- a/src/lib/schemas.js +++ b/src/lib/schemas.js @@ -6,7 +6,7 @@ import {ResourceType} from "./schemas/resourcetype.js"; import {ServiceProviderConfig} from "./schemas/spconfig.js"; /** - * SCIM Schemas Container Class + * SCIMMY Schemas Container Class * @namespace SCIMMY.Schemas * @description * SCIMMY provides a singleton class, `SCIMMY.Schemas`, that is used to declare schema definitions implemented by a SCIM Service Provider. From 357cf1cb2630bca5277f38c6259c2150b2fe767c Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 3 Apr 2023 17:31:41 +1000 Subject: [PATCH 46/93] Chore(deps): bump all devDependencies to latest versions --- jsdoc.json | 7 +++++-- package.json | 15 ++++++++------- packager.js | 10 ++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/jsdoc.json b/jsdoc.json index dd9d262..c2cd5ee 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -1,11 +1,14 @@ { - "plugins": ["plugins/markdown"], + "plugins": [ + "plugins/markdown", + "classy-template/plugin" + ], "source": { "include": ["src"] }, "opts": { "recurse": true, - "template": "./node_modules/classy-template", + "template": "classy-template", "destination": "./docs", "readme": "./README.md", "package": "./package.json" diff --git a/package.json b/package.json index 567d3ce..528050e 100644 --- a/package.json +++ b/package.json @@ -41,12 +41,13 @@ "url": "https://github.com/scimmyjs/scimmy/issues" }, "devDependencies": { - "chalk": "^4.1.2", - "classy-template": "^1.0.2", - "jsdoc": "^3.6.7", - "minimist": "^1.2.5", - "mocha": "^9.1.3", - "rollup": "^2.60.0", - "typescript": "^4.4.4" + "@types/node": "^18.15.11", + "chalk": "^5.2.0", + "classy-template": "^1.2.0", + "jsdoc": "^4.0.2", + "minimist": "^1.2.8", + "mocha": "^10.2.0", + "rollup": "^3.20.2", + "typescript": "^5.0.2" } } diff --git a/packager.js b/packager.js index d97c60c..b0c0f33 100644 --- a/packager.js +++ b/packager.js @@ -40,7 +40,7 @@ export class Packager { /** * Create a step function to consistently log action's results - * @param {Boolean} verbose - whether or not to show extended info about action's results + * @param {Boolean} verbose - whether to show extended info about action's results * @returns {Function} step function */ static action(verbose = true) { @@ -103,7 +103,7 @@ export class Packager { /** * Build the SCIMMY library - * @param {Boolean} [verbose=false] - whether or not to show extended output from each step of the build + * @param {Boolean} [verbose=false] - whether to show extended output from each step of the build * @returns {Promise} a promise that resolves when the build has completed */ static async build(verbose = false) { @@ -186,10 +186,12 @@ export class Packager { const output = []; const config = { exports: "auto", - preferConst: true, + manualChunks: chunks, minifyInternalExports: false, hoistTransitiveImports: false, - manualChunks: chunks + generatedCode: { + constBindings: true + } }; // Prepare RollupJS bundle with supplied entry point From 444a253aa2161c6eee8fb65732f7fe6914162d90 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 5 Apr 2023 13:39:54 +1000 Subject: [PATCH 47/93] fix(SCIMMY.Types.SchemaDefinition): missing extension schemas from namespaced attributes --- src/lib/types/definition.js | 143 ++++++++++++++++++----------------- test/lib/types/definition.js | 124 +++++++++++++++--------------- 2 files changed, 134 insertions(+), 133 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index 938f807..c6fae99 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -78,10 +78,10 @@ export class SchemaDefinition { attribute(name) { if (name.toLowerCase().startsWith("urn:")) { // Handle namespaced attributes by looking for a matching extension - let extension = (this.attributes.find(a => a instanceof SchemaDefinition && name.toLowerCase().startsWith(a.id.toLowerCase())) - ?? (name.toLowerCase().startsWith(`${this.id.toLowerCase()}:`) || name.toLowerCase() === this.id.toLowerCase() ? this : false)), - // Get the actual attribute name minus extension ID - attribute = (extension ? name.substring(extension.id.length+1) : ""); + const extension = (this.attributes.find(a => a instanceof SchemaDefinition && name.toLowerCase().startsWith(a.id.toLowerCase())) + ?? (name.toLowerCase().startsWith(`${this.id.toLowerCase()}:`) || name.toLowerCase() === this.id.toLowerCase() ? this : false)); + // Get the actual attribute name minus extension ID + const attribute = (extension ? name.substring(extension.id.length+1) : ""); // Bail out if no schema extension found with matching ID if (!extension) @@ -91,11 +91,11 @@ export class SchemaDefinition { return (!attribute.length ? extension : extension.attribute(attribute)); } else { // Break name into path parts in case of search for sub-attributes - let path = name.split("."), - // Find the first attribute in the path - target = path.shift(), - attribute = this.attributes.find(a => a instanceof Attribute && a.name.toLowerCase() === target.toLowerCase()), - spent = [target]; + const path = name.split("."); + const spent = [path.shift()]; + // Find the first attribute in the path + let [target] = spent, + attribute = this.attributes.find(a => a instanceof Attribute && a.name.toLowerCase() === target.toLowerCase()); // If nothing was found, the attribute isn't declared by the schema definition if (attribute === undefined) @@ -130,8 +130,8 @@ export class SchemaDefinition { * @returns {SCIMMY.Types.SchemaDefinition} this schema definition instance for chaining */ extend(extension = [], required) { - let attribs = this.attributes.map(a => a instanceof SchemaDefinition ? Object.getPrototypeOf(a) : a), - extensions = (Array.isArray(extension) ? extension : [extension]); + const attribs = this.attributes.map(a => a instanceof SchemaDefinition ? Object.getPrototypeOf(a) : a); + const extensions = (Array.isArray(extension) ? extension : [extension]); // If the extension is a schema definition, add it to the schema definition instance if (extension instanceof SchemaDefinition) { @@ -151,7 +151,7 @@ export class SchemaDefinition { } // Go through the schema extension definition and directly register any nested schema definitions - let surplusSchemas = extension.attributes.filter(e => e instanceof SchemaDefinition); + const surplusSchemas = extension.attributes.filter(e => e instanceof SchemaDefinition); for (let definition of surplusSchemas) this.extend(definition); } // If every extension is an attribute instance, add them to the schema definition @@ -183,11 +183,11 @@ export class SchemaDefinition { for (let attrib of (Array.isArray(attributes) ? attributes : [attributes])) { if (this.attributes.includes(attrib)) { // Remove a found attribute from the schema definition - let index = this.attributes.indexOf(attrib); + const index = this.attributes.indexOf(attrib); if (index >= 0) this.attributes.splice(index, 1); } else if (typeof attrib === "string") { // Look for the target attribute to remove, which throws a TypeError if not found - let target = this.attribute(attrib); + const target = this.attribute(attrib); // Either try truncate again with the target attribute if (!attrib.includes(".")) this.truncate(target); @@ -212,71 +212,72 @@ export class SchemaDefinition { if (data === undefined || Array.isArray(data) || Object(data) !== data) throw new TypeError("Expected 'data' parameter to be an object in SchemaDefinition instance"); - let filter = (filters ?? []).slice(0).shift(), - target = {}, - // Compile a list of schema IDs to include in the resource - schemas = [...new Set([ - this.id, - ...(this.attributes.filter(a => a instanceof SchemaDefinition) - .map(s => s.id).filter(id => !!data[id])), - ...(Array.isArray(data.schemas) ? data.schemas : []) - ])], - // Add schema IDs, and schema's name as resource type to meta attribute - source = { - // Cast all key names to lower case to eliminate case sensitivity.... - ...(Object.keys(data).reduce((res, key) => (((res[key.toLowerCase()] = data[key]) || true) && res), {})), - schemas: schemas, meta: { - ...(data?.meta ?? {}), resourceType: this.name, - ...(typeof basepath === "string" ? {location: `${basepath}${!!data.id ? `/${data.id}` : ""}`} : {}) - } - }; + // Get the filter and coercion target ready + const filter = (filters ?? []).slice(0).shift(); + const target = {}; + // Compile a list of schema IDs to include in the resource + const schemas = [...new Set([ + this.id, + ...(this.attributes.filter(a => a instanceof SchemaDefinition).map(s => s.id) + .filter(id => !!data[id] || Object.keys(data).some(d => d.startsWith(`${id}:`)))), + ...(Array.isArray(data.schemas) ? data.schemas : []) + ])]; + // Add schema IDs, and schema's name as resource type to meta attribute + const source = { + // Cast all key names to lower case to eliminate case sensitivity.... + ...(Object.keys(data).reduce((res, key) => Object.assign(res, {[key.toLowerCase()]: data[key]}), {})), + schemas, meta: { + ...(data?.meta ?? {}), resourceType: this.name, + ...(typeof basepath === "string" ? {location: `${basepath}${!!data.id ? `/${data.id}` : ""}`} : {}) + } + }; // Go through all attributes and coerce them for (let attribute of this.attributes) { if (attribute instanceof Attribute) { - let {name} = attribute, - // Evaluate the coerced value - value = attribute.coerce(source[name.toLowerCase()], direction); + // Evaluate the coerced value + const {name} = attribute; + const value = attribute.coerce(source[name.toLowerCase()], direction); // If it's defined, add it to the target if (value !== undefined) target[name] = value; } else if (attribute instanceof SchemaDefinition) { - let {id: name, required} = attribute, - // Get any values from the source that begin with the extension ID - namespacedValues = Object.keys(source).filter(k => k.startsWith(`${name.toLowerCase()}:`)) - // Get the actual attribute name and value - .map(k => [k.replace(`${name.toLowerCase()}:`, ""), source[k]]) - .reduce((res = {}, [name, value]) => { - // Get attribute path parts and actual value - let parts = name.toLowerCase().split("."), - parent = res, - target = {[parts.pop()]: value}; - - // Traverse as deep as necessary - while (parts.length > 0) { - let path = parts.shift(); - parent = (parent[path] = parent[path] ?? {}); - } - - // Assign and return - Object.assign(parent, target); - return res; - }, undefined), - // Mix the namespaced attribute values in with the extension value - mixedSource = [source[name.toLowerCase()] ?? {}, namespacedValues ?? {}].reduce(function merge(t, s) { - // Cast all key names to lower case to eliminate case sensitivity.... - t = (Object.keys(t).reduce((res, key) => (((res[key.toLowerCase()] = t[key]) || true) && res), {})); + const {id: name, required} = attribute; + // Get any values from the source that begin with the extension ID + const namespacedValues = Object.keys(source).filter(k => k.startsWith(`${name.toLowerCase()}:`)) + // Get the actual attribute name and value + .map(k => [k.replace(`${name.toLowerCase()}:`, ""), source[k]]) + .reduce((res = {}, [name, value]) => { + // Get attribute path parts and actual value + const parts = name.toLowerCase().split("."); + const target = {[parts.pop()]: value}; + let parent = res; - // Merge all properties from s into t, joining arrays and objects - for (let skey of Object.keys(s)) { - let tkey = skey.toLowerCase(); - if (Array.isArray(t[tkey]) && Array.isArray(s[skey])) t[tkey].push(...s[skey]); - else if (s[skey] !== Object(s[skey])) t[tkey] = s[skey]; - else t[tkey] = merge(t[tkey] ?? {}, s[skey]); + // Traverse as deep as necessary + while (parts.length > 0) { + const path = parts.shift(); + parent = (parent[path] = parent[path] ?? {}); } - return t; - }, {}); + // Assign and return + Object.assign(parent, target); + return res; + }, undefined); + // Mix the namespaced attribute values in with the extension value + const mixedSource = [source[name.toLowerCase()] ?? {}, namespacedValues ?? {}].reduce(function merge(t, s) { + // Cast all key names to lower case to eliminate case sensitivity.... + t = (Object.keys(t).reduce((res, key) => Object.assign(res, {[key.toLowerCase()]: t[key]}), {})); + + // Merge all properties from s into t, joining arrays and objects + for (let skey of Object.keys(s)) { + const tkey = skey.toLowerCase(); + if (Array.isArray(t[tkey]) && Array.isArray(s[skey])) t[tkey].push(...s[skey]); + else if (s[skey] !== Object(s[skey])) t[tkey] = s[skey]; + else t[tkey] = merge(t[tkey] ?? {}, s[skey]); + } + + return t; + }, {}); // Attempt to coerce the schema extension if (!!required && !Object.keys(mixedSource).length) { @@ -318,7 +319,7 @@ export class SchemaDefinition { // Check for any negative filters for (let key in {...filter}) { // Find the attribute by lower case name - let {name, config: {returned} = {}} = attributes.find(a => a.name.toLowerCase() === key.toLowerCase()) ?? {}; + const {name, config: {returned} = {}} = attributes.find(a => a.name.toLowerCase() === key.toLowerCase()) ?? {}; if (returned !== "always" && Array.isArray(filter[key]) && filter[key][0] === "np") { // Remove the property from the result, and remove the spent filter @@ -331,7 +332,7 @@ export class SchemaDefinition { if (!Object.keys(filter).length) return data; else { // Prepare resultant value storage - let target = {} + const target = {} // Go through every value in the data and filter attributes for (let key in data) { @@ -353,7 +354,7 @@ export class SchemaDefinition { target[key] = data[key]; // Otherwise if the filter is defined and the attribute is complex, evaluate it else if (key in filter && type === "complex") { - let value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); + const value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); // Only set the value if it isn't empty if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length)) diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 20c5717..074c71b 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -67,14 +67,14 @@ export let SchemaDefinitionSuite = (SCIMMY) => { assert.strictEqual((new SCIMMY.Types.SchemaDefinition(...Object.values(params)))?.id, params.id, "SchemaDefinition did not include instance member 'id'"); }); - + it("should have instance member 'description'", () => { assert.ok("description" in (new SCIMMY.Types.SchemaDefinition(...Object.values(params))), "SchemaDefinition did not include instance member 'description'"); }); it("should have instance member 'attributes' that is an array", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.ok("attributes" in definition, "SchemaDefinition did not include instance member 'attributes'"); @@ -89,10 +89,10 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should produce valid SCIM schema definition objects", async () => { - let {describe: suite} = await fixtures; + const {describe: suite} = await fixtures; for (let fixture of suite) { - let definition = new SCIMMY.Types.SchemaDefinition( + const definition = new SCIMMY.Types.SchemaDefinition( fixture.source.name, fixture.source.id, fixture.source.description, fixture.source.attributes.map((a) => instantiateFromFixture(SCIMMY, a)) ); @@ -110,8 +110,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should find attributes by name", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute("id"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("id"); assert.ok(attribute !== undefined, "Instance method 'attribute' did not return anything"); @@ -128,16 +128,16 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should ignore case of 'name' argument when finding attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute("id"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("id"); assert.strictEqual(definition.attribute("ID"), attribute, "Instance method 'attribute' did not ignore case of 'name' argument when finding attributes"); }); it("should find sub-attributes by name", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute("meta.resourceType"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); assert.ok(attribute !== undefined, "Instance method 'attribute' did not return anything"); @@ -148,7 +148,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect sub-attributes to exist", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.attribute("id.test"), {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, @@ -159,16 +159,16 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should ignore case of 'name' argument when finding sub-attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute("meta.resourceType"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); assert.strictEqual(definition.attribute("Meta.ResourceType"), attribute, "Instance method 'attribute' did not ignore case of 'name' argument when finding sub-attributes"); }); it("should find namespaced attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute(`${params.id}:id`); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute(`${params.id}:id`); assert.ok(attribute !== undefined, "Instance method 'attribute' did not return anything"); @@ -179,7 +179,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect namespaced attributes to exist", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.attribute(`${params.id}:test`), {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, @@ -196,8 +196,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should ignore case of 'name' argument when finding namespaced attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute(`${params.id}:id`); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute(`${params.id}:id`); assert.strictEqual(definition.attribute(String(`${params.id}:id`).toUpperCase()), attribute, "Instance method 'attribute' did not ignore case of 'name' argument when finding namespaced attributes"); @@ -211,7 +211,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.extend({}), {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, @@ -225,7 +225,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect all attribute extensions to have unique names", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.extend(new SCIMMY.Types.Attribute("string", "id")), {name: "TypeError", message: `Schema definition '${params.id}' already declares attribute 'id'`}, @@ -233,9 +233,9 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should do nothing when Attribute instance extensions are already included in the schema definition", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - extension = new SCIMMY.Types.Attribute("string", "test"), - attribute = definition.attribute("id"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const extension = new SCIMMY.Types.Attribute("string", "test"); + const attribute = definition.attribute("id"); assert.strictEqual(definition.extend([attribute, extension]).attribute("id"), attribute, "Instance method 'extend' did not ignore already included Attribute instance extension"); @@ -244,8 +244,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect all schema definition extensions to have unique IDs", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - extension = new SCIMMY.Types.SchemaDefinition("ExtensionTest", SCIMMY.Schemas.User.definition.id); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const extension = new SCIMMY.Types.SchemaDefinition("ExtensionTest", SCIMMY.Schemas.User.definition.id); assert.throws(() => definition.extend(SCIMMY.Schemas.User.definition).extend(extension), {name: "TypeError", message: `Schema definition '${params.id}' already declares extension '${SCIMMY.Schemas.User.definition.id}'`}, @@ -253,8 +253,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should do nothing when SchemaDefinition instances are already declared as extensions to the schema definition", () => { - let extension = new SCIMMY.Types.SchemaDefinition(`${params.name}Extension`, `${params.id}Extension`), - definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)).extend(extension); + const extension = new SCIMMY.Types.SchemaDefinition(`${params.name}Extension`, `${params.id}Extension`); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)).extend(extension); assert.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension, "Instance method 'extend' did not ignore already declared SchemaDefinition extension"); @@ -267,46 +267,46 @@ export let SchemaDefinitionSuite = (SCIMMY) => { "Instance method 'truncate' not defined"); }); - it("should do nothing without arguments", async () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - expected = JSON.parse(JSON.stringify(definition.describe())), - actual = JSON.parse(JSON.stringify(definition.truncate().describe())); + it("should do nothing without arguments", () => { + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const actual = JSON.parse(JSON.stringify(definition.truncate().describe())); assert.deepStrictEqual(actual, expected, "Instance method 'truncate' modified attributes without arguments"); }); it("should do nothing when definition does not directly include Attribute instances in 'attributes' argument", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - expected = JSON.parse(JSON.stringify(definition.describe())), - attribute = new SCIMMY.Types.Attribute("string", "id"), - actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const attribute = new SCIMMY.Types.Attribute("string", "id"); + const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); assert.deepStrictEqual(actual, expected, "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); }); it("should remove Attribute instances directly included in the definition", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test"), - definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [attribute]), - expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})), - actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); + const attribute = new SCIMMY.Types.Attribute("string", "test"); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [attribute]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); assert.deepStrictEqual(actual, expected, "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); }); it("should remove named attributes directly included in the definition", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [new SCIMMY.Types.Attribute("string", "test")]), - expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})), - actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [new SCIMMY.Types.Attribute("string", "test")]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); assert.deepStrictEqual(actual, expected, "Instance method 'truncate' did not remove named attribute directly included in the definition"); }); it("should expect named attributes and sub-attributes to exist", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.truncate("test"), {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, @@ -327,7 +327,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect 'data' argument to be an object", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); assert.throws(() => definition.coerce(), {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, @@ -341,8 +341,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect common attributes to be defined on coerced result", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - result = definition.coerce({}); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const result = definition.coerce({}); assert.ok(Array.isArray(result.schemas) && result.schemas.includes(params.id), "Instance method 'coerce' did not set common attribute 'schemas' on coerced result"); @@ -351,7 +351,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect coerce to be called on directly included attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ new SCIMMY.Types.Attribute("string", "test", {required: true}) ]); @@ -364,7 +364,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect namespaced attributes or extensions to be coerced", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)) + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)) .extend(SCIMMY.Schemas.EnterpriseUser.definition, true); assert.throws(() => definition.coerce({}), @@ -383,10 +383,10 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect the supplied filter to be applied to coerced result", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ new SCIMMY.Types.Attribute("string", "testName"), new SCIMMY.Types.Attribute("string", "testValue") - ]), - result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new SCIMMY.Types.Filter("testName pr")); + ]); + const result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new SCIMMY.Types.Filter("testName pr")); assert.ok(Object.keys(result).includes("testName"), "Instance method 'coerce' did not include attributes for filter 'testName pr'"); @@ -395,17 +395,17 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ - new SCIMMY.Types.Attribute("string", "employeeNumber")]).extend(SCIMMY.Schemas.EnterpriseUser.definition), - result = definition.coerce( - { - employeeNumber: "Test", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", - }, - undefined, undefined, - new SCIMMY.Types.Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") - ); + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema",[new SCIMMY.Types.Attribute("string", "employeeNumber")]) + .extend(SCIMMY.Schemas.EnterpriseUser.definition); + const result = definition.coerce( + { + employeeNumber: "Test", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", + }, + undefined, undefined, + new SCIMMY.Types.Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") + ); assert.strictEqual(result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].employeeNumber, "1234", "Instance method 'coerce' did not include namespaced attributes for filter"); From 68b4711ee7f2fa13997c4b4816c3b59e1abed183 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 5 Apr 2023 16:02:40 +1000 Subject: [PATCH 48/93] Fix(SCIMMY.Messages.PatchOp): also patch inbound attributes --- src/lib/messages/patchop.js | 67 +++++++++++++++++----------------- src/lib/resources/group.js | 6 +-- src/lib/resources/user.js | 6 +-- src/lib/types/attribute.js | 44 +++++++++++----------- test/lib/messages/patchop.js | 64 +++++++++++++------------------- test/lib/messages/patchop.json | 7 ++++ 6 files changed, 93 insertions(+), 101 deletions(-) diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index afa6440..ff246f3 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -46,7 +46,7 @@ export class PatchOp { * @property {Object[]} Operations - list of SCIM-compliant patch operations to apply to the given resource */ constructor(request) { - let {schemas = [], Operations: operations = []} = request ?? {}; + const {schemas = [], Operations: operations = []} = request ?? {}; // Determine if message is being prepared (outbound) or has been dispatched (inbound) this.#dispatched = (request !== undefined); @@ -63,8 +63,8 @@ export class PatchOp { // Make sure all specified operations are valid for (let operation of operations) { - let index = (operations.indexOf(operation) + 1), - {op, path, value} = operation; + const index = (operations.indexOf(operation) + 1); + const {op, path, value} = operation; // Make sure operation is of type 'complex' (i.e. it's an object) if (Object(operation) !== operation || Array.isArray(operation)) @@ -130,12 +130,12 @@ export class PatchOp { // Store details about the resource being patched this.#schema = resource.constructor.definition; this.#source = resource; - this.#target = new resource.constructor(resource, "out"); + this.#target = new resource.constructor(resource); // Go through all specified operations for (let operation of this.Operations) { - let index = (this.Operations.indexOf(operation) + 1), - {op, path, value} = operation; + const index = (this.Operations.indexOf(operation) + 1); + const {op, path, value} = operation; // And action it switch (op.toLowerCase()) { @@ -159,7 +159,7 @@ export class PatchOp { // If finalise is a method, feed it the target to retrieve final representation of resource if (typeof finalise === "function") - this.#target = new this.#target.constructor(await finalise(this.#target), "out"); + this.#target = new this.#target.constructor(await finalise(this.#target)); // Only return value if something has changed if (!isDeepStrictEqual({...this.#source, meta: undefined}, {...this.#target, meta: undefined})) @@ -176,9 +176,9 @@ export class PatchOp { */ #resolve(index, path, op) { // Work out parts of the supplied path - let paths = path.split(pathSeparator).filter(p => p), - targets = [this.#target], - property, attribute, multiValued; + const paths = path.split(pathSeparator).filter(p => p); + const targets = [this.#target]; + let property, attribute, multiValued; try { // Remove any filters from the path and attempt to get targeted attribute definition @@ -191,9 +191,9 @@ export class PatchOp { // Traverse the path while (paths.length > 0) { - let path = paths.shift(), - // Work out if path contains a filter expression - [, key = path, filter] = multiValuedFilter.exec(path) ?? []; + // Work out if path contains a filter expression + const path = paths.shift(); + const [, key = path, filter] = multiValuedFilter.exec(path) ?? []; // We have arrived at our destination if (paths.length === 0) { @@ -231,9 +231,7 @@ export class PatchOp { */ return { complex: (attribute instanceof Types.SchemaDefinition ? true : attribute.type === "complex"), - multiValued: multiValued, - property: property, - targets: targets + multiValued, property, targets }; } @@ -268,7 +266,7 @@ export class PatchOp { } } else { // Validate and extract details about the operation - let {targets, property, multiValued, complex} = this.#resolve(index, path, "add"); + const {targets, property, multiValued, complex} = this.#resolve(index, path, "add"); // Go and apply the operation to matching targets for (let target of targets) { @@ -276,7 +274,7 @@ export class PatchOp { // The target is expected to be a collection if (multiValued) { // Wrap objects as arrays - let values = (Array.isArray(value) ? value : [value]); + const values = (Array.isArray(value) ? value : [value]); // Add the values to the existing collection, or create a new one if it doesn't exist yet if (Array.isArray(target[property])) target[property].push(...values); @@ -307,7 +305,7 @@ export class PatchOp { */ #remove(index, path, value) { // Validate and extract details about the operation - let {targets, property, complex, multiValued} = this.#resolve(index, path, "remove"); + const {targets, property, complex, multiValued} = this.#resolve(index, path, "remove"); // If there's a property defined, we have an easy target for removal if (property) { @@ -316,20 +314,22 @@ export class PatchOp { try { // No value filter defined, or target is not multi-valued - unset the property if (value === undefined || !multiValued) target[property] = undefined; - // Multi-valued target, attempt removal of matching values from attribute + // Multivalued target, attempt removal of matching values from attribute else if (multiValued) { // Make sure filter values is an array for easy use of "includes" comparison when filtering - let values = (Array.isArray(value) ? value : [value]), - // If values are complex, build a filter to match with - otherwise just use values - removals = (!complex || values.every(v => Object.isFrozen(v)) ? values : new Types.Filter( - values.map(f => Object.entries(f) - // Get rid of any empty values from the filter - .filter(([, value]) => value !== undefined) - // Turn it into an equity filter string - .map(([key, value]) => (`${key} eq ${value}`)).join(" and ")) - .join(" or ")) - // Get any matching values from the filter - .match(target[property])); + const values = (Array.isArray(value) ? value : [value]); + // If values are complex, build a filter to match with - otherwise just use values + const removals = (!complex || values.every(v => Object.isFrozen(v)) ? values : ( + new Types.Filter(values.map(f => Object.entries(f) + // Get rid of any empty values from the filter + .filter(([, value]) => value !== undefined) + // Turn it into an equity filter string + .map(([key, value]) => (`${key} eq ${value}`)).join(" and ")) + .join(" or ") + ) + // Get any matching values from the filter + .match(target[property]) + )); // Filter out any values that exist in removals list target[property] = (target[property] ?? []).filter(v => !removals.includes(v)); @@ -343,7 +343,7 @@ export class PatchOp { } } else { // Get path to the parent attribute having values removed - let parentPath = path.split(pathSeparator).filter(v => v) + const parentPath = path.split(pathSeparator).filter(v => v) .map((path, index, paths) => (index < paths.length-1 ? path : path.replace(multiValuedFilter, "$1"))) .join("."); @@ -378,8 +378,7 @@ export class PatchOp { } } catch (ex) { // Rethrow exceptions with 'replace' instead of 'add' or 'remove' - let forReplaceOp = "for 'replace' op"; - ex.message = ex.message.replace("for 'add' op", forReplaceOp).replace("for 'remove' op", forReplaceOp); + ex.message = ex.message.replace(/for '(add|remove)' op/, "for 'replace' op"); throw ex; } } diff --git a/src/lib/resources/group.js b/src/lib/resources/group.js index dd2b6d5..b66ad2a 100644 --- a/src/lib/resources/group.js +++ b/src/lib/resources/group.js @@ -139,9 +139,9 @@ export class Group extends Types.Resource { throw new Types.Error(400, "invalidSyntax", "PatchOp request expected message body to be single complex value"); try { - return await new Messages.PatchOp(message) - .apply(new Schemas.Group((await Group.#egress(this) ?? []).shift(), "out"), - async (instance) => await Group.#ingress(this, instance)) + return await Promise.resolve(new Messages.PatchOp(message) + .apply(new Schemas.Group((await Group.#egress(this) ?? []).shift()), + async (instance) => await Group.#ingress(this, instance))) .then(instance => !instance ? undefined : new Schemas.Group(instance, "out", Group.basepath(), this.attributes)); } catch (ex) { if (ex instanceof Types.Error) throw ex; diff --git a/src/lib/resources/user.js b/src/lib/resources/user.js index 0ac459c..932460c 100644 --- a/src/lib/resources/user.js +++ b/src/lib/resources/user.js @@ -139,9 +139,9 @@ export class User extends Types.Resource { throw new Types.Error(400, "invalidSyntax", "PatchOp request expected message body to be single complex value"); try { - return await new Messages.PatchOp(message) - .apply(new Schemas.User((await User.#egress(this) ?? []).shift(), "out"), - async (instance) => await User.#ingress(this, instance)) + return await Promise.resolve(new Messages.PatchOp(message) + .apply(new Schemas.User((await User.#egress(this) ?? []).shift()), + async (instance) => await User.#ingress(this, instance))) .then(instance => !instance ? undefined : new Schemas.User(instance, "out", User.basepath(), this.attributes)); } catch (ex) { if (ex instanceof Types.Error) throw ex; diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index f622c30..bbf0880 100644 --- a/src/lib/types/attribute.js +++ b/src/lib/types/attribute.js @@ -131,7 +131,7 @@ const validate = { */ string: (attrib, value) => { if (typeof value !== "string" && value !== null) { - let type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); // Catch array and object values as they will not cast to string as expected throw new TypeError(`Attribute '${attrib.name}' expected ` + (Array.isArray(value) @@ -145,8 +145,8 @@ const validate = { * @param {*} value - the value being validated */ date: (attrib, value) => { - let date = new Date(value), - type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const date = new Date(value); + const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); // Reject values that definitely aren't dates if (["number", "complex", "boolean"].includes(type) || (type === "string" && date.toString() === "Invalid Date")) @@ -165,10 +165,10 @@ const validate = { * @param {*} value - the value being validated */ number: (attrib, value) => { - let {type, name} = attrib, - isNum = !!String(value).match(/^-?\d+?(\.\d+)?$/), - isInt = isNum && !String(value).includes("."), - actual = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const {type, name} = attrib; + const isNum = !!String(value).match(/^-?\d+?(\.\d+)?$/); + const isInt = isNum && !String(value).includes("."); + const actual = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); if (typeof value === "object" && value !== null) { // Catch case where value is an object or array @@ -196,7 +196,7 @@ const validate = { let message; if (typeof value === "object" && value !== null) { - let type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); // Catch case where value is an object or array if (Array.isArray(value)) message = `Attribute '${attrib.name}' expected single value of type 'binary'`; @@ -223,7 +223,7 @@ const validate = { */ boolean: (attrib, value) => { if (typeof value !== "boolean" && value !== null) { - let type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); // Catch array and object values as they will not cast to string as expected throw new TypeError(`Attribute '${attrib.name}' expected ` + (Array.isArray(value) @@ -237,15 +237,15 @@ const validate = { * @param {*} value - the value being validated */ reference: (attrib, value) => { - let listReferences = (attrib.config.referenceTypes || []).map(t => `'${t}'`).join(", "), - coreReferences = (attrib.config.referenceTypes || []).filter(t => ["uri", "external"].includes(t)), - typeReferences = (attrib.config.referenceTypes || []).filter(t => !["uri", "external"].includes(t)), - message; + const listReferences = (attrib.config.referenceTypes || []).map(t => `'${t}'`).join(", "); + const coreReferences = (attrib.config.referenceTypes || []).filter(t => ["uri", "external"].includes(t)); + const typeReferences = (attrib.config.referenceTypes || []).filter(t => !["uri", "external"].includes(t)); + let message; // If there's no value and the attribute isn't required, skip validation if (value === undefined && !attrib?.config?.required) return; else if (typeof value !== "string" && value !== null) { - let type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); + const type = (value instanceof Date ? "dateTime" : typeof value === "object" ? "complex" : typeof value); // Catch case where value is an object or array if (Array.isArray(value)) message = `Attribute '${attrib.name}' expected single value of type 'reference'`; @@ -305,9 +305,9 @@ export class Attribute { * @property {SCIMMY.Types.Attribute[]} [subAttributes] - if the attribute is complex, the sub-attributes of the attribute */ constructor(type, name, config = {}, subAttributes = []) { - let errorSuffix = `attribute definition '${name}'`, - // Check for invalid characters in attribute name - [, invalidNameChar] = /^(?:.*?)([^$\-_a-zA-Z0-9])(?:.*?)$/g.exec(name) ?? []; + const errorSuffix = `attribute definition '${name}'`; + // Check for invalid characters in attribute name + const [, invalidNameChar] = /^(?:.*?)([^$\-_a-zA-Z0-9])(?:.*?)$/g.exec(name) ?? []; // Make sure name and type are supplied as strings for (let [param, value] of [["type", type], ["name", name]]) if (typeof value !== "string") @@ -351,7 +351,7 @@ export class Attribute { for (let subAttrib of (Array.isArray(subAttributes) ? subAttributes : [subAttributes])) { if (this.subAttributes.includes(subAttrib)) { // Remove found subAttribute from definition - let index = this.subAttributes.indexOf(subAttrib); + const index = this.subAttributes.indexOf(subAttrib); if (index >= 0) this.subAttributes.splice(index, 1); } else if (typeof subAttrib === "string") { // Attempt to find the subAttribute by name and try truncate again @@ -412,11 +412,11 @@ export class Attribute { */ coerce(source, direction = "both", isComplexMultiValue = false) { // Make sure the direction matches the attribute direction - if (["both", direction].includes(this.config.direction)) { - let {required, multiValued, canonicalValues} = this.config; + if (["both", this.config.direction].includes(direction) || this.config.direction === "both") { + const {required, multiValued, canonicalValues} = this.config; // If the attribute is required, make sure it has a value - if ((source === undefined || source === null) && required) + if ((source === undefined || source === null) && required && this.config.direction === direction) throw new TypeError(`Required attribute '${this.name}' is missing`); // If the attribute is multi-valued, make sure its value is a collection if (source !== undefined && !isComplexMultiValue && multiValued && !Array.isArray(source)) @@ -518,7 +518,7 @@ export class Attribute { // Go through each sub-attribute for coercion for (let subAttribute of this.subAttributes) { - let {name} = subAttribute; + const {name} = subAttribute; // Predefine getters and setters for all possible sub-attributes Object.defineProperties(target, { diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index c1ceb43..80b6fb8 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -136,53 +136,39 @@ export let PatchOpSuite = (SCIMMY) => { {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); }); - - it("should support simple and complex 'add' operations", async () => { - let {inbound: {add: suite}} = await fixtures; - - for (let fixture of suite) { - let source = new SCIMMY.Schemas.User(fixture.source, "out"), - expected = new SCIMMY.Schemas.User(fixture.target, "out"), - message = new SCIMMY.Messages.PatchOp({...template, Operations: fixture.ops}); + + for (let op of ["add", "remove", "replace"]) { + it(`should support simple and complex '${op}' operations`, async () => { + const {inbound: {[op]: suite}} = await fixtures; - assert.deepStrictEqual(await message.apply(source), expected, - `PatchOp 'apply' did not support 'add' op specified in inbound fixture ${suite.indexOf(fixture)+1}`); - } - }); + for (let fixture of suite) { + const message = new SCIMMY.Messages.PatchOp({...template, Operations: fixture.ops}); + const source = new SCIMMY.Schemas.User(fixture.source); + const expected = new SCIMMY.Schemas.User(fixture.target, "out"); + const actual = new SCIMMY.Schemas.User(await message.apply(source, (patched) => { + const expected = JSON.parse(JSON.stringify({...fixture.target, meta: undefined})); + const actual = JSON.parse(JSON.stringify({...patched, schemas: undefined, meta: undefined})); + + // Also make sure the resource is handled correctly during finalisation + assert.deepStrictEqual(actual, expected, + `PatchOp 'apply' patched resource unexpectedly in '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); + + return patched; + }), "out"); + + assert.deepStrictEqual(actual, expected, + `PatchOp 'apply' did not support '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); + } + }); + } it("should expect 'value' to be an object when 'path' is not specified in 'add' operations", async () => { await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"}, "out")), + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Attribute 'value' must be an object when 'path' is empty for 'add' op of operation 1 in PatchOp request body"}, "PatchOp did not expect 'value' to be an object when 'path' was not specified in 'add' operations"); }); - - it("should support simple and complex 'remove' operations", async () => { - let {inbound: {remove: suite}} = await fixtures; - - for (let fixture of suite) { - let source = new SCIMMY.Schemas.User(fixture.source, "out"), - expected = new SCIMMY.Schemas.User(fixture.target, "out"), - message = new SCIMMY.Messages.PatchOp({...template, Operations: fixture.ops}); - - assert.deepStrictEqual(await message.apply(source), expected, - `PatchOp 'apply' did not support 'remove' op specified in inbound fixture ${suite.indexOf(fixture)+1}`); - } - }); - - it("should support simple and complex 'replace' operations", async () => { - let {inbound: {replace: suite}} = await fixtures; - - for (let fixture of suite) { - let source = new SCIMMY.Schemas.User(fixture.source, "out"), - expected = new SCIMMY.Schemas.User(fixture.target, "out"), - message = new SCIMMY.Messages.PatchOp({...template, Operations: fixture.ops}); - - assert.deepStrictEqual(await message.apply(source), expected, - `PatchOp 'apply' did not support 'replace' op specified in inbound fixture ${suite.indexOf(fixture)+1}`); - } - }); }); }); } \ No newline at end of file diff --git a/test/lib/messages/patchop.json b/test/lib/messages/patchop.json index 658b9a4..edb7fe4 100644 --- a/test/lib/messages/patchop.json +++ b/test/lib/messages/patchop.json @@ -8,6 +8,13 @@ {"op": "add", "value": {"displayName": "asdf", "name": {"honorificPrefix": "Mr"}}}, {"op": "add", "path": "nickName", "value": "dsaf"} ] + }, + { + "source": {"id": "1234", "userName": "asdf"}, + "target": {"id": "1234", "userName": "asdf", "password": "1234"}, + "ops": [ + {"op": "add", "path": "password", "value": "1234"} + ] } ], "remove": [ From e6081d939225abec1bea2adce5f0b87ed0275751 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 5 Apr 2023 16:05:24 +1000 Subject: [PATCH 49/93] Fix(SCIMMY.Messages.PatchOp): rethrow replace op errors when not target related --- src/lib/messages/patchop.js | 18 ++++++++---- test/lib/messages/patchop.js | 56 +++++++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index ff246f3..e3c1fe8 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -258,6 +258,9 @@ export class PatchOp { // Add additional context to SCIM errors ex.message += ` for 'add' op of operation ${index} in PatchOp request body`; throw ex; + } else if (ex.message?.endsWith?.("object is not extensible")) { + // Handle errors caused by non-existent attributes in complex values + throw new Types.Error(400, "invalidValue", `Invalid attribute '${key}' for supplied value of 'add' operation ${index} in PatchOp request body`); } else { // Rethrow other exceptions as SCIM errors throw new Types.Error(400, "invalidValue", `Value '${val}' not valid for attribute '${key}' of 'add' operation ${index} in PatchOp request body`); @@ -364,17 +367,22 @@ export class PatchOp { // Call remove, then call add! try { if (path !== undefined) this.#remove(index, path); - } catch { - // Do nothing, remove target doesn't exist + } catch (ex) { + // Only rethrow if error is anything other than target doesn't exist + if (ex.scimType !== "noTarget") throw ex; } try { // Try set the value at the path this.#add(index, path, value); - } catch { + } catch (ex) { // If it's a multi-value target that doesn't exist, add to the collection instead - this.#add(index, path.split(pathSeparator).filter(p => p) - .map((p, i, s) => (i < s.length - 1 ? p : p.replace(multiValuedFilter, "$1"))).join("."), value); + if (ex.scimType === "noTarget") { + this.#add(index, path.split(pathSeparator).filter(p => p) + .map((p, i, s) => (i < s.length - 1 ? p : p.replace(multiValuedFilter, "$1"))).join("."), value); + } + // Otherwise, rethrow the error + else throw ex; } } catch (ex) { // Rethrow exceptions with 'replace' instead of 'add' or 'remove' diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index 80b6fb8..966f46e 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -84,7 +84,7 @@ export let PatchOpSuite = (SCIMMY) => { ]}); } catch (ex) { if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { - let op = ex.message.replace("Invalid operation '").split("'").unshift(); + const op = ex.message.replace("Invalid operation '").split("'").unshift(); assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); } } @@ -105,7 +105,7 @@ export let PatchOpSuite = (SCIMMY) => { }); it("should expect all patch op 'path' values to be strings in 'Operations' attribute of 'request' parameter", () => { - let operations = [ + const operations = [ {op: "remove", path: 1}, {op: "remove", path: true}, {op: "add", value: 1, path: false} @@ -131,12 +131,20 @@ export let PatchOpSuite = (SCIMMY) => { "PatchOp did not expect message to be dispatched before proceeding with 'apply' method"); }); - it("should expect 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { + it("should expect 'resource' parameter to be defined", async () => { await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(), {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, - "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); + "PatchOp did not expect 'resource' parameter to be defined in 'apply' method"); }); - + + it("should expect 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { + for (let value of [{}, new Date()]) { + await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(value), + {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, + "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); + } + }); + for (let op of ["add", "remove", "replace"]) { it(`should support simple and complex '${op}' operations`, async () => { const {inbound: {[op]: suite}} = await fixtures; @@ -160,15 +168,37 @@ export let PatchOpSuite = (SCIMMY) => { `PatchOp 'apply' did not support '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); } }); + + if (["add", "replace"].includes(op)) { + it(`should expect 'value' to be an object when 'path' is not specified in '${op}' operations`, async () => { + await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op, value: false}]}) + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Attribute 'value' must be an object when 'path' is empty for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); + }); + } + + it(`should respect attribute mutability in '${op}' operations`, async () => { + const Operations = [{op, path: "id", ...(op === "add" ? {value: "asdf"} : {})}]; + + await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations}) + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not respect attribute mutability in '${op}' operations`); + }); + + it(`should not remove required attributes in '${op}' operations`, async () => { + const Operations = [{op, path: "userName", ...(op === "add" ? {value: null} : {})}]; + + await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations}) + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Required attribute 'userName' is missing for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp removed required attributes in '${op}' operations`); + }); } - - it("should expect 'value' to be an object when 'path' is not specified in 'add' operations", async () => { - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Attribute 'value' must be an object when 'path' is empty for 'add' op of operation 1 in PatchOp request body"}, - "PatchOp did not expect 'value' to be an object when 'path' was not specified in 'add' operations"); - }); }); }); } \ No newline at end of file From 97ec919e520d67a1c74557103db9ad1c04b4b6b5 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 5 Apr 2023 16:22:47 +1000 Subject: [PATCH 50/93] Tests(SCIMMY.Messages): group constructor tests for clarity --- test/lib/messages/bulkrequest.js | 274 +++++++++++++++-------------- test/lib/messages/bulkresponse.js | 38 ++-- test/lib/messages/error.js | 84 ++++----- test/lib/messages/patchop.js | 206 +++++++++++----------- test/lib/messages/searchrequest.js | 198 ++++++++++----------- test/scimmy.js | 2 +- 6 files changed, 406 insertions(+), 396 deletions(-) diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index df70a0a..92c02ee 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -30,7 +30,7 @@ export let BulkRequestSuite = (SCIMMY) => { // Mock write method that assigns IDs and stores in static instances array async write(instance) { // Give the instance an ID and assign data to it - let target = Object.assign((!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), + const target = Object.assign((!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined}))); // Save the instance if necessary and return it @@ -49,72 +49,74 @@ export let BulkRequestSuite = (SCIMMY) => { assert.ok(!!SCIMMY.Messages.BulkRequest, "Static class 'BulkRequest' not defined")); describe("SCIMMY.Messages.BulkRequest", () => { - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkRequest instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkRequest instantiated with invalid 'schemas' property"); - }); - - it("should expect 'Operations' attribute of 'request' argument to be an array", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: "a string"}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, - "BulkRequest instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); - }); - - it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkRequest instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: []}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkRequest instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { - let fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: value}), + describe("#constructor", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: ["nonsense"]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, - `BulkRequest instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); - } - }); - - it("should expect 'maxOperations' argument to be a positive integer, if specified", () => { - let fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, value), + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: [params.id, "nonsense"]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, - `BulkRequest instantiated with invalid 'maxOperations' parameter ${label}`); - } - }); - - it("should expect number of operations to not exceed 'maxOperations' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, 1), - {name: "SCIMError", status: 413, scimType: null, - message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, - "BulkRequest instantiated with number of operations exceeding 'maxOperations' parameter"); + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkRequest instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: "a string"}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, + "BulkRequest instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + + it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkRequest instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: []}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkRequest instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: value}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, + `BulkRequest instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); + } + }); + + it("should expect 'maxOperations' argument to be a positive integer, if specified", () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, + `BulkRequest instantiated with invalid 'maxOperations' parameter ${label}`); + } + }); + + it("should expect number of operations to not exceed 'maxOperations' argument", () => { + assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, 1), + {name: "SCIMError", status: 413, scimType: null, + message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, + "BulkRequest instantiated with number of operations exceeding 'maxOperations' parameter"); + }); }); describe("#apply()", () => { @@ -130,28 +132,28 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should expect 'method' attribute to have a value for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations, - expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) - }})); + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations; + const expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) + }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'method' attribute to be present for each operation"); }); it("should expect 'method' attribute to be a string for each operation", async () => { - let fixtures = [ + const fixtures = [ ["boolean value 'false'", false], ["negative integer value '-1'", -1], ["complex value", {}] ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations, - expected = [{status: "400", method: value, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations; + const expected = [{status: "400", method: value, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, `Instance method 'apply' did not reject 'method' attribute ${label}`); @@ -159,38 +161,38 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations, - expected = [{status: "400", method: "a string", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations; + const expected = [{status: "400", method: "a string", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not reject invalid 'method' string value 'a string'"); }); it("should expect 'path' attribute to have a value for each operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations, - expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) - }})); + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) + }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'path' attribute to be present for each operation"); }); it("should expect 'path' attribute to be a string for each operation", async () => { - let fixtures = [ + const fixtures = [ ["boolean value 'false'", false], ["negative integer value '-1'", -1], ["complex value", {}] ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations, - expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, `Instance method 'apply' did not reject 'path' attribute ${label}`); @@ -198,58 +200,58 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations, - expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'path' attribute to refer to a valid resource type endpoint"); }); it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations, - expected = [{status: "404", method: "POST", bulkId: "asdf", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations; + const expected = [{status: "404", method: "POST", bulkId: "asdf", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'path' attribute not to specify a resource ID when 'method' was POST"); }); it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations, - expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) - }})); + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations; + const expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) + }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'path' attribute to specify a resource ID when 'method' was not POST"); }); it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations, - expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) - }})); + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) + }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'bulkId' attribute to be present for each 'POST' operation"); }); it("should expect 'bulkId' attribute to be a string for each 'POST' operation", async () => { - let fixtures = [ + const fixtures = [ ["boolean value 'false'", false], ["negative integer value '-1'", -1], ["complex value", {}] ]; for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations, - expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, `Instance method 'apply' did not reject 'path' attribute ${label}`); @@ -257,34 +259,34 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations, - expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) - }})); + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) + }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'apply' did not expect 'data' attribute to be present when 'method' was not DELETE"); }); it("should expect 'data' attribute to be a single complex value when 'method' is not DELETE", async () => { - let suite = [ - {method: "POST", path: "/Test", bulkId: "asdf"}, - {method: "PUT", path: "/Test/1"}, - {method: "PATCH", path: "/Test/1"} - ], - fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1] - ]; + const suite = [ + {method: "POST", path: "/Test", bulkId: "asdf"}, + {method: "PUT", path: "/Test/1"}, + {method: "PATCH", path: "/Test/1"} + ]; + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1] + ]; for (let op of suite) { for (let [label, value] of fixtures) { - let actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations, - expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) - }}]; + const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, `Instance method 'apply' did not reject 'data' attribute ${label}`); @@ -293,13 +295,13 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should stop processing operations when failOnErrors limit is reached", async () => { - let {inbound: {failOnErrors: suite}} = await fixtures; + const {inbound: {failOnErrors: suite}} = await fixtures; assert.ok((await (new SCIMMY.Messages.BulkRequest({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, "Instance method 'apply' did not stop processing when failOnErrors limit reached"); for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); + const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not stop processing in inbound failOnErrors fixture #${suite.indexOf(fixture)+1}`); @@ -307,10 +309,10 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should resolve bulkId references that are out of order", async () => { - let {inbound: {bulkId: {unordered: suite}}} = await fixtures; + const {inbound: {bulkId: {unordered: suite}}} = await fixtures; for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); + const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not resolve references in inbound bulkId unordered fixture #${suite.indexOf(fixture)+1}`); @@ -318,10 +320,10 @@ export let BulkRequestSuite = (SCIMMY) => { }); it("should resolve bulkId references that are circular", async () => { - let {inbound: {bulkId: {circular: suite}}} = await fixtures; + const {inbound: {bulkId: {circular: suite}}} = await fixtures; for (let fixture of suite) { - let result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); + const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js index 0def747..697a36a 100644 --- a/test/lib/messages/bulkresponse.js +++ b/test/lib/messages/bulkresponse.js @@ -8,24 +8,26 @@ export let BulkResponseSuite = (SCIMMY) => { assert.ok(!!SCIMMY.Messages.BulkResponse, "Static class 'BulkResponse' not defined")); describe("SCIMMY.Messages.BulkResponse", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.BulkResponse())}, template, - "BulkResponse did not instantiate with correct default properties"); - }); - - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: ["nonsense"]}), - {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, - "BulkResponse instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: [params.id, "nonsense"]}), - {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, - "BulkResponse instantiated with invalid 'schemas' property"); - }); - - it("should expect 'Operations' attribute of 'request' argument to be an array", () => { - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: template.schemas, Operations: "a string"}), - {name: "TypeError", message: "BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"}, - "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + describe("#constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SCIMMY.Messages.BulkResponse())}, template, + "BulkResponse did not instantiate with correct default properties"); + }); + + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: ["nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: [params.id, "nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: template.schemas, Operations: "a string"}), + {name: "TypeError", message: "BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"}, + "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); }); describe("#resolve()", () => { diff --git a/test/lib/messages/error.js b/test/lib/messages/error.js index 876222c..e5380e9 100644 --- a/test/lib/messages/error.js +++ b/test/lib/messages/error.js @@ -13,49 +13,51 @@ export let ErrorSuite = (SCIMMY) => { assert.ok(!!SCIMMY.Messages.Error, "Static class 'Error' not defined")); describe("SCIMMY.Messages.Error", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.Error())}, template, - "SCIM Error message did not instantiate with correct default properties"); - }); - - it("should rethrow inbound SCIM Error messages at instantiation", async () => { - let {inbound: suite} = await fixtures; + describe("#constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SCIMMY.Messages.Error())}, template, + "SCIM Error message did not instantiate with correct default properties"); + }); - for (let fixture of suite) { - assert.throws(() => new SCIMMY.Messages.Error(fixture), - {name: "SCIMError", message: fixture.detail, status: fixture.status, scimType: fixture.scimType}, - `Inbound SCIM Error message fixture #${suite.indexOf(fixture)+1} not rethrown at instantiation`); - } - }); - - it("should not accept invalid HTTP status codes for 'status' parameter", () => { - assert.throws(() => new SCIMMY.Messages.Error({status: "a string"}), - {name: "TypeError", message: "Incompatible HTTP status code 'a string' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'status' parameter 'a string'"); - assert.throws(() => new SCIMMY.Messages.Error({status: 402}), - {name: "TypeError", message: "Incompatible HTTP status code '402' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'status' parameter '402'"); - }); - - it("should not accept unknown values for 'scimType' parameter", () => { - assert.throws(() => new SCIMMY.Messages.Error({scimType: "a string"}), - {name: "TypeError", message: "Unknown detail error keyword 'a string' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'scimType' parameter 'a string'"); - }); - - it("should verify 'scimType' value is valid for a given 'status' code", async () => { - let {outbound: {valid, invalid}} = await fixtures; + it("should rethrow inbound SCIM Error messages at instantiation", async () => { + const {inbound: suite} = await fixtures; + + for (let fixture of suite) { + assert.throws(() => new SCIMMY.Messages.Error(fixture), + {name: "SCIMError", message: fixture.detail, status: fixture.status, scimType: fixture.scimType}, + `Inbound SCIM Error message fixture #${suite.indexOf(fixture)+1} not rethrown at instantiation`); + } + }); - for (let fixture of valid) { - assert.deepStrictEqual({...(new SCIMMY.Messages.Error(fixture))}, {...template, ...fixture}, - `Error message type check 'valid' fixture #${valid.indexOf(fixture) + 1} did not produce expected output`); - } - - for (let fixture of invalid) { - assert.throws(() => new SCIMMY.Messages.Error(fixture), - {name: "TypeError", message: `HTTP status code '${fixture.status}' not valid for detail error keyword '${fixture.scimType}' in SCIM Error Message constructor`}, - `Error message instantiated with invalid 'scimType' and 'status' parameters in type check 'invalid' fixture #${valid.indexOf(fixture) + 1}`); - } + it("should not accept invalid HTTP status codes for 'status' parameter", () => { + assert.throws(() => new SCIMMY.Messages.Error({status: "a string"}), + {name: "TypeError", message: "Incompatible HTTP status code 'a string' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'status' parameter 'a string'"); + assert.throws(() => new SCIMMY.Messages.Error({status: 402}), + {name: "TypeError", message: "Incompatible HTTP status code '402' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'status' parameter '402'"); + }); + + it("should not accept unknown values for 'scimType' parameter", () => { + assert.throws(() => new SCIMMY.Messages.Error({scimType: "a string"}), + {name: "TypeError", message: "Unknown detail error keyword 'a string' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'scimType' parameter 'a string'"); + }); + + it("should verify 'scimType' value is valid for a given 'status' code", async () => { + const {outbound: {valid, invalid}} = await fixtures; + + for (let fixture of valid) { + assert.deepStrictEqual({...(new SCIMMY.Messages.Error(fixture))}, {...template, ...fixture}, + `Error message type check 'valid' fixture #${valid.indexOf(fixture) + 1} did not produce expected output`); + } + + for (let fixture of invalid) { + assert.throws(() => new SCIMMY.Messages.Error(fixture), + {name: "TypeError", message: `HTTP status code '${fixture.status}' not valid for detail error keyword '${fixture.scimType}' in SCIM Error Message constructor`}, + `Error message instantiated with invalid 'scimType' and 'status' parameters in type check 'invalid' fixture #${valid.indexOf(fixture) + 1}`); + } + }); }); }); } \ No newline at end of file diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index 966f46e..e22d80c 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -13,110 +13,112 @@ export let PatchOpSuite = (SCIMMY) => { assert.ok(!!SCIMMY.Messages.PatchOp, "Static class 'PatchOp' not defined")); describe("SCIMMY.Messages.PatchOp", () => { - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, - "PatchOp instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, - "PatchOp instantiated with invalid 'schemas' property"); - }); - - it("should expect 'Operations' attribute of 'request' parameter to be an array", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: "a string"}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp expects 'Operations' attribute of 'request' parameter to be an array"}, - "PatchOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); - }); - - it("should expect at least one patch op in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, - "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: []}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, - "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all patch ops to be 'complex' values in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, "a string"]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'string'"}, - `PatchOp instantiated with invalid patch op 'a string' in 'Operations' attribute of 'request' parameter`); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, true]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'boolean'"}, - `PatchOp instantiated with invalid patch op 'true' in 'Operations' attribute of 'request' parameter`); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, []]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'collection'"}, - `PatchOp instantiated with invalid patch op '[]' in 'Operations' attribute of 'request' parameter`); - }); - - it("should expect all patch ops to have an 'op' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'op' from operation 1 in PatchOp request body"}, - "PatchOp instantiated with invalid patch op '{}' in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {value: "a string"}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'op' from operation 2 in PatchOp request body"}, - `PatchOp instantiated with invalid patch op '{value: "a string"}' in 'Operations' attribute of 'request' parameter`); - }); - - it("should not accept unknown 'op' values in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "a string"}]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Invalid operation 'a string' for operation 1 in PatchOp request body"}, - "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); - }); - - it("should ignore case of 'op' values in 'Operations' attribute of 'request' parameter", () => { - try { - new SCIMMY.Messages.PatchOp({...template, Operations: [ - {op: "Add", value: {}}, {op: "ADD", value: {}}, {op: "aDd", value: {}}, - {op: "Remove", path: "test"}, {op: "REMOVE", path: "test"}, {op: "rEmOvE", path: "test"}, - {op: "Replace", value: {}}, {op: "REPLACE", value: {}}, {op: "rEpLaCe", value: {}}, - ]}); - } catch (ex) { - if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { - const op = ex.message.replace("Invalid operation '").split("'").unshift(); - assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); + describe("#constructor", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, + "PatchOp instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, + "PatchOp instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' parameter to be an array", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: "a string"}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp expects 'Operations' attribute of 'request' parameter to be an array"}, + "PatchOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + + it("should expect at least one patch op in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, + "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: []}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, + "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all patch ops to be 'complex' values in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, "a string"]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'string'"}, + `PatchOp instantiated with invalid patch op 'a string' in 'Operations' attribute of 'request' parameter`); + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, true]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'boolean'"}, + `PatchOp instantiated with invalid patch op 'true' in 'Operations' attribute of 'request' parameter`); + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, []]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'collection'"}, + `PatchOp instantiated with invalid patch op '[]' in 'Operations' attribute of 'request' parameter`); + }); + + it("should expect all patch ops to have an 'op' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'op' from operation 1 in PatchOp request body"}, + "PatchOp instantiated with invalid patch op '{}' in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {value: "a string"}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'op' from operation 2 in PatchOp request body"}, + `PatchOp instantiated with invalid patch op '{value: "a string"}' in 'Operations' attribute of 'request' parameter`); + }); + + it("should not accept unknown 'op' values in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "a string"}]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Invalid operation 'a string' for operation 1 in PatchOp request body"}, + "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); + }); + + it("should ignore case of 'op' values in 'Operations' attribute of 'request' parameter", () => { + try { + new SCIMMY.Messages.PatchOp({...template, Operations: [ + {op: "Add", value: {}}, {op: "ADD", value: {}}, {op: "aDd", value: {}}, + {op: "Remove", path: "test"}, {op: "REMOVE", path: "test"}, {op: "rEmOvE", path: "test"}, + {op: "Replace", value: {}}, {op: "REPLACE", value: {}}, {op: "rEpLaCe", value: {}}, + ]}); + } catch (ex) { + if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { + const op = ex.message.replace("Invalid operation '").split("'").unshift(); + assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); + } } - } - }); - - it("should expect all 'add' ops to have a 'value' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {op: "add", value: false}, {op: "add", path: "test"}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'value' for 'add' op of operation 3 in PatchOp request body"}, - "PatchOp instantiated with missing 'value' value for 'add' op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all 'remove' ops to have a 'path' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "remove", path: "test"}, {op: "remove"}]}), - {name: "SCIMError", status: 400, scimType: "noTarget", - message: "Missing required attribute 'path' for 'remove' op of operation 2 in PatchOp request body"}, - "PatchOp instantiated with missing 'path' value for 'remove' op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all patch op 'path' values to be strings in 'Operations' attribute of 'request' parameter", () => { - const operations = [ - {op: "remove", path: 1}, - {op: "remove", path: true}, - {op: "add", value: 1, path: false} - ]; + }); - for (let op of operations) { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [op]}), - {name: "SCIMError", status: 400, scimType: "invalidPath", - message: `Invalid path '${op.path}' for operation 1 in PatchOp request body`}, - `PatchOp instantiated with invalid 'path' value '${op.path}' in 'Operations' attribute of 'request' parameter`); - } + it("should expect all 'add' ops to have a 'value' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {op: "add", value: false}, {op: "add", path: "test"}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'value' for 'add' op of operation 3 in PatchOp request body"}, + "PatchOp instantiated with missing 'value' value for 'add' op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all 'remove' ops to have a 'path' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "remove", path: "test"}, {op: "remove"}]}), + {name: "SCIMError", status: 400, scimType: "noTarget", + message: "Missing required attribute 'path' for 'remove' op of operation 2 in PatchOp request body"}, + "PatchOp instantiated with missing 'path' value for 'remove' op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all patch op 'path' values to be strings in 'Operations' attribute of 'request' parameter", () => { + const operations = [ + {op: "remove", path: 1}, + {op: "remove", path: true}, + {op: "add", value: 1, path: false} + ]; + + for (let op of operations) { + assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [op]}), + {name: "SCIMError", status: 400, scimType: "invalidPath", + message: `Invalid path '${op.path}' for operation 1 in PatchOp request body`}, + `PatchOp instantiated with invalid 'path' value '${op.path}' in 'Operations' attribute of 'request' parameter`); + } + }); }); describe("#apply()", () => { diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index d0dde66..19ca4b2 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -30,104 +30,106 @@ export let SearchRequestSuite = (SCIMMY) => { assert.ok(!!SCIMMY.Messages.SearchRequest, "Static class 'SearchRequest' not defined")); describe("SCIMMY.Messages.SearchRequest", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.SearchRequest())}, template, - "SearchRequest did not instantiate with correct default properties"); - }); - - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, - "SearchRequest instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, - "SearchRequest instantiated with invalid 'schemas' property"); - }); - - it("should expect 'filter' property of 'request' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, filter: "test"}), - "SearchRequest did not instantiate with valid 'filter' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, filter: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'filter' parameter to be a non-empty string"}, - `SearchRequest instantiated with invalid 'filter' property ${label}`); - } - }); - - it("should expect 'excludedAttributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), - "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, - `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); - } - }); - - it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), - "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, - `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); - } - }); - - it("should expect 'sortBy' property of 'request' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: "test"}), - "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'sortBy' parameter to be a non-empty string"}, - `SearchRequest instantiated with invalid 'sortBy' property ${label}`); - } - }); - - it("should expect 'sortOrder' property of 'request' argument to be either 'ascending' or 'descending', if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: "ascending"}), - "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, - `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); - } - }); - - it("should expect 'startIndex' property of 'request' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: 1}), - "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'startIndex' parameter to be a positive integer"}, - `SearchRequest instantiated with invalid 'startIndex' property ${label}`); - } - }); - - it("should expect 'count' property of 'request' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, count: 1}), - "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, count: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'count' parameter to be a positive integer"}, - `SearchRequest instantiated with invalid 'count' property ${label}`); - } + describe("#constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SCIMMY.Messages.SearchRequest())}, template, + "SearchRequest did not instantiate with correct default properties"); + }); + + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + }); + + it("should expect 'filter' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, filter: "test"}), + "SearchRequest did not instantiate with valid 'filter' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, filter: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'filter' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'filter' property ${label}`); + } + }); + + it("should expect 'excludedAttributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'sortBy' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: "test"}), + "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortBy' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'sortBy' property ${label}`); + } + }); + + it("should expect 'sortOrder' property of 'request' argument to be either 'ascending' or 'descending', if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: "ascending"}), + "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, + `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); + } + }); + + it("should expect 'startIndex' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: 1}), + "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'startIndex' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'startIndex' property ${label}`); + } + }); + + it("should expect 'count' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, count: 1}), + "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, count: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'count' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'count' property ${label}`); + } + }); }); describe("#prepare()", () => { diff --git a/test/scimmy.js b/test/scimmy.js index 029a99e..68ec2dc 100644 --- a/test/scimmy.js +++ b/test/scimmy.js @@ -11,4 +11,4 @@ describe("SCIMMY", () => { MessagesSuite(SCIMMY); SchemasSuite(SCIMMY); ResourcesSuite(SCIMMY); -}) \ No newline at end of file +}); \ No newline at end of file From ad57108e26817402dd305a9b0b2707f119dfb756 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 7 Apr 2023 14:00:49 +1000 Subject: [PATCH 51/93] Chore: add c8 for checking test coverage --- package.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package.json b/package.json index 528050e..9316340 100644 --- a/package.json +++ b/package.json @@ -37,11 +37,23 @@ "type": "git", "url": "git+https://github.com/scimmyjs/scimmy.git" }, + "c8": { + "all": true, + "check-coverage": true, + "include": [ + "src/**/*.js" + ], + "reporter": [ + "clover", + "lcov" + ] + }, "bugs": { "url": "https://github.com/scimmyjs/scimmy/issues" }, "devDependencies": { "@types/node": "^18.15.11", + "c8": "^7.13.0", "chalk": "^5.2.0", "classy-template": "^1.2.0", "jsdoc": "^4.0.2", From e350bfcee4680f7f018833ab3b5d5bfa869dd0ea Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 7 Apr 2023 14:57:45 +1000 Subject: [PATCH 52/93] Tests(*): import SCIMMY instead of passing through callback --- package.json | 5 + test/lib/config.js | 9 +- test/lib/messages.js | 17 +- test/lib/messages/bulkrequest.js | 81 +-- test/lib/messages/bulkresponse.js | 11 +- test/lib/messages/error.js | 17 +- test/lib/messages/listresponse.js | 37 +- test/lib/messages/patchop.js | 15 +- test/lib/messages/searchrequest.js | 55 ++- test/lib/resources.js | 768 +++++++++++++++-------------- test/lib/resources/group.js | 12 +- test/lib/resources/resourcetype.js | 12 +- test/lib/resources/schema.js | 12 +- test/lib/resources/spconfig.js | 12 +- test/lib/resources/user.js | 12 +- test/lib/schemas.js | 239 ++++----- test/lib/schemas/enterpriseuser.js | 12 +- test/lib/schemas/group.js | 12 +- test/lib/schemas/resourcetype.js | 12 +- test/lib/schemas/spconfig.js | 12 +- test/lib/schemas/user.js | 12 +- test/lib/types.js | 17 +- test/lib/types/attribute.js | 353 +++++++------ test/lib/types/definition.js | 22 +- test/lib/types/error.js | 5 +- test/lib/types/filter.js | 23 +- test/lib/types/resource.js | 5 +- test/lib/types/schema.js | 5 +- test/scimmy.js | 11 +- 29 files changed, 946 insertions(+), 869 deletions(-) diff --git a/package.json b/package.json index 9316340..cd51d78 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,11 @@ "type": "git", "url": "git+https://github.com/scimmyjs/scimmy.git" }, + "imports": { + "#@/*": { + "default": "./src/*" + } + }, "c8": { "all": true, "check-coverage": true, diff --git a/test/lib/config.js b/test/lib/config.js index c9ab117..87b0712 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -1,4 +1,5 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; function returnsImmutableObject(name, config) { assert.ok(Object(config) === config && !Array.isArray(config), @@ -8,7 +9,7 @@ function returnsImmutableObject(name, config) { `Static method '${name}' returned a mutable object`); } -export let ConfigSuite = (SCIMMY) => { +export const ConfigSuite = () => { it("should include static class 'Config'", () => assert.ok(!!SCIMMY.Config, "Static class 'Config' not defined")); @@ -24,7 +25,7 @@ export let ConfigSuite = (SCIMMY) => { }); describe(".set()", () => { - let origin = JSON.parse(JSON.stringify(SCIMMY.Config.get())); + const origin = JSON.parse(JSON.stringify(SCIMMY.Config.get())); after(() => SCIMMY.Config.set(origin)); it("should have static method 'set'", () => { @@ -36,7 +37,7 @@ export let ConfigSuite = (SCIMMY) => { returnsImmutableObject("set", SCIMMY.Config.set())); it("should do nothing without arguments", () => { - let config = SCIMMY.Config.get(); + const config = SCIMMY.Config.get(); assert.deepStrictEqual(SCIMMY.Config.set(), config, "Static method 'set' unexpectedly modified config"); @@ -111,4 +112,4 @@ export let ConfigSuite = (SCIMMY) => { } }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages.js b/test/lib/messages.js index 72ad4c3..06068dc 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -1,4 +1,5 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; import {ErrorSuite} from "./messages/error.js"; import {ListResponseSuite} from "./messages/listresponse.js"; import {PatchOpSuite} from "./messages/patchop.js"; @@ -6,16 +7,16 @@ import {BulkRequestSuite} from "./messages/bulkrequest.js"; import {BulkResponseSuite} from "./messages/bulkresponse.js"; import {SearchRequestSuite} from "./messages/searchrequest.js"; -export let MessagesSuite = (SCIMMY) => { +export const MessagesSuite = () => { it("should include static class 'Messages'", () => assert.ok(!!SCIMMY.Messages, "Static class 'Messages' not defined")); describe("SCIMMY.Messages", () => { - ErrorSuite(SCIMMY); - ListResponseSuite(SCIMMY); - PatchOpSuite(SCIMMY); - BulkRequestSuite(SCIMMY); - BulkResponseSuite(SCIMMY); - SearchRequestSuite(SCIMMY); + ErrorSuite(); + ListResponseSuite(); + PatchOpSuite(); + BulkRequestSuite(); + BulkResponseSuite(); + SearchRequestSuite(); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index 92c02ee..b432e48 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -2,49 +2,52 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let BulkRequestSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./bulkrequest.json"), "utf8").then((f) => JSON.parse(f)); - const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; - const template = {schemas: [params.id], Operations: [{}, {}]}; +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./bulkrequest.json"), "utf8").then((f) => JSON.parse(f)); +const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; +const template = {schemas: [params.id], Operations: [{}, {}]}; + +/** + * BulkRequest Test Resource Class + * Because BulkRequest needs a set of implemented resources to test against + */ +class Test extends SCIMMY.Types.Resource { + // Store some helpful things for the mock methods + static #lastId = 0; + static #instances = []; + static reset() { + Test.#lastId = 0; + Test.#instances = []; + return Test; + } - /** - * BulkRequest Test Resource Class - * Because BulkRequest needs a set of implemented resources to test against - */ - class Test extends SCIMMY.Types.Resource { - // Store some helpful things for the mock methods - static #lastId = 0; - static #instances = []; - static reset() { - Test.#lastId = 0; - Test.#instances = []; - return Test; - } - - // Endpoint/basepath required by all Resource implementations - static endpoint = "/Test"; - static basepath() {} - - // Mock write method that assigns IDs and stores in static instances array - async write(instance) { - // Give the instance an ID and assign data to it - const target = Object.assign((!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), - JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined}))); - - // Save the instance if necessary and return it - if (!Test.#instances.includes(target)) Test.#instances.push(target); - return {...target, meta: {location: `/Test/${target.id}`}}; - } + // Endpoint/basepath required by all Resource implementations + static endpoint = "/Test"; + static basepath() {} + + // Mock write method that assigns IDs and stores in static instances array + async write(instance) { + // Give the instance an ID and assign data to it + const target = Object.assign( + (!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), + JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) + ); - // Mock dispose method that removes from static instances array - async dispose() { - if (this.id) Test.#instances.splice(Test.#instances.indexOf(Test.#instances.find(i => i.id === this.id)), 1); - else throw new SCIMMY.Types.Error(404, null, "DELETE operation must target a specific resource"); - } + // Save the instance if necessary and return it + if (!Test.#instances.includes(target)) Test.#instances.push(target); + return {...target, meta: {location: `/Test/${target.id}`}}; } + // Mock dispose method that removes from static instances array + async dispose() { + if (this.id) Test.#instances.splice(Test.#instances.indexOf(Test.#instances.find(i => i.id === this.id)), 1); + else throw new SCIMMY.Types.Error(404, null, "DELETE operation must target a specific resource"); + } +} + +export const BulkRequestSuite = () => { it("should include static class 'BulkRequest'", () => assert.ok(!!SCIMMY.Messages.BulkRequest, "Static class 'BulkRequest' not defined")); @@ -331,4 +334,4 @@ export let BulkRequestSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js index 697a36a..8b8b7ba 100644 --- a/test/lib/messages/bulkresponse.js +++ b/test/lib/messages/bulkresponse.js @@ -1,9 +1,10 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let BulkResponseSuite = (SCIMMY) => { - const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; - const template = {schemas: [params.id], Operations: []}; - +const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; +const template = {schemas: [params.id], Operations: []}; + +export const BulkResponseSuite = () => { it("should include static class 'BulkResponse'", () => assert.ok(!!SCIMMY.Messages.BulkResponse, "Static class 'BulkResponse' not defined")); @@ -42,4 +43,4 @@ export let BulkResponseSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/error.js b/test/lib/messages/error.js index e5380e9..e899845 100644 --- a/test/lib/messages/error.js +++ b/test/lib/messages/error.js @@ -2,13 +2,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let ErrorSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./error.json"), "utf8").then((f) => JSON.parse(f)); - const params = {id: "urn:ietf:params:scim:api:messages:2.0:Error"}; - const template = {schemas: [params.id], status: "500"}; - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./error.json"), "utf8").then((f) => JSON.parse(f)); +const params = {id: "urn:ietf:params:scim:api:messages:2.0:Error"}; +const template = {schemas: [params.id], status: "500"}; + +export const ErrorSuite = () => { it("should include static class 'Error'", () => assert.ok(!!SCIMMY.Messages.Error, "Static class 'Error' not defined")); @@ -51,7 +52,7 @@ export let ErrorSuite = (SCIMMY) => { assert.deepStrictEqual({...(new SCIMMY.Messages.Error(fixture))}, {...template, ...fixture}, `Error message type check 'valid' fixture #${valid.indexOf(fixture) + 1} did not produce expected output`); } - + for (let fixture of invalid) { assert.throws(() => new SCIMMY.Messages.Error(fixture), {name: "TypeError", message: `HTTP status code '${fixture.status}' not valid for detail error keyword '${fixture.scimType}' in SCIM Error Message constructor`}, @@ -60,4 +61,4 @@ export let ErrorSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/listresponse.js b/test/lib/messages/listresponse.js index 48ef653..a1f32b8 100644 --- a/test/lib/messages/listresponse.js +++ b/test/lib/messages/listresponse.js @@ -2,13 +2,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let ListResponseSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./listresponse.json"), "utf8").then((f) => JSON.parse(f)); - const params = {id: "urn:ietf:params:scim:api:messages:2.0:ListResponse"}; - const template = {schemas: [params.id], Resources: [], totalResults: 0, startIndex: 1, itemsPerPage: 20}; - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./listresponse.json"), "utf8").then((f) => JSON.parse(f)); +const params = {id: "urn:ietf:params:scim:api:messages:2.0:ListResponse"}; +const template = {schemas: [params.id], Resources: [], totalResults: 0, startIndex: 1, itemsPerPage: 20}; + +export const ListResponseSuite = () => { it("should include static class 'ListResponse'", () => assert.ok(!!SCIMMY.Messages.ListResponse, "Static class 'ListResponse' not defined")); @@ -37,7 +38,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should use 'startIndex' value included in inbound requests", async () => { - let {inbound: suite} = await fixtures; + const {inbound: suite} = await fixtures; for (let fixture of suite) { assert.ok((new SCIMMY.Messages.ListResponse(fixture, {startIndex: 20})).startIndex === fixture.startIndex, @@ -54,7 +55,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should use 'itemsPerPage' value included in inbound requests", async () => { - let {inbound: suite} = await fixtures; + const {inbound: suite} = await fixtures; for (let fixture of suite) { assert.ok((new SCIMMY.Messages.ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage === fixture.itemsPerPage, @@ -83,7 +84,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should have instance member 'Resources' that is an array", () => { - let list = new SCIMMY.Messages.ListResponse(); + const list = new SCIMMY.Messages.ListResponse(); assert.ok("Resources" in list, "Instance member 'Resources' not defined"); @@ -92,7 +93,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should have instance member 'totalResults' that is a non-negative integer", () => { - let list = new SCIMMY.Messages.ListResponse(); + const list = new SCIMMY.Messages.ListResponse(); assert.ok("totalResults" in list, "Instance member 'totalResults' not defined"); @@ -103,7 +104,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should use 'totalResults' value included in inbound requests", async () => { - let {inbound: suite} = await fixtures; + const {inbound: suite} = await fixtures; for (let fixture of suite) { assert.ok((new SCIMMY.Messages.ListResponse(fixture, {totalResults: 200})).totalResults === fixture.totalResults, @@ -113,7 +114,7 @@ export let ListResponseSuite = (SCIMMY) => { for (let member of ["startIndex", "itemsPerPage"]) { it(`should have instance member '${member}' that is a positive integer`, () => { - let list = new SCIMMY.Messages.ListResponse(); + const list = new SCIMMY.Messages.ListResponse(); assert.ok(member in list, `Instance member '${member}' not defined`); @@ -125,8 +126,8 @@ export let ListResponseSuite = (SCIMMY) => { } it("should only sort resources if 'sortBy' parameter is supplied", async () => { - let {outbound: {source}} = await fixtures, - list = new SCIMMY.Messages.ListResponse(source, {sortOrder: "descending"}); + const {outbound: {source}} = await fixtures; + const list = new SCIMMY.Messages.ListResponse(source, {sortOrder: "descending"}); for (let item of source) { assert.ok(item.id === list.Resources[source.indexOf(item)]?.id, @@ -135,10 +136,10 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { - let {outbound: {source, targets: suite}} = await fixtures; + const {outbound: {source, targets: suite}} = await fixtures; for (let fixture of suite) { - let list = new SCIMMY.Messages.ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); + const list = new SCIMMY.Messages.ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); assert.deepStrictEqual(list.Resources.map(r => r.id), fixture.expected, `ListResponse did not correctly sort outbound target #${suite.indexOf(fixture)+1} by 'sortBy' value '${fixture.sortBy}'`); @@ -146,7 +147,7 @@ export let ListResponseSuite = (SCIMMY) => { }); it("should not include more resources than 'itemsPerPage' parameter", async () => { - let {outbound: {source}} = await fixtures; + const {outbound: {source}} = await fixtures; for (let length of [2, 5, 10, 200, 1]) { assert.ok((new SCIMMY.Messages.ListResponse(source, {itemsPerPage: length})).Resources.length <= length, @@ -154,4 +155,4 @@ export let ListResponseSuite = (SCIMMY) => { } }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index e22d80c..fa34c68 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -2,13 +2,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let PatchOpSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./patchop.json"), "utf8").then((f) => JSON.parse(f)); - const params = {id: "urn:ietf:params:scim:api:messages:2.0:PatchOp"}; - const template = {schemas: [params.id]}; - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./patchop.json"), "utf8").then((f) => JSON.parse(f)); +const params = {id: "urn:ietf:params:scim:api:messages:2.0:PatchOp"}; +const template = {schemas: [params.id]}; + +export const PatchOpSuite = () => { it("should include static class 'PatchOp'", () => assert.ok(!!SCIMMY.Messages.PatchOp, "Static class 'PatchOp' not defined")); @@ -203,4 +204,4 @@ export let PatchOpSuite = (SCIMMY) => { } }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index 19ca4b2..9c140d1 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -1,31 +1,32 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let SearchRequestSuite = (SCIMMY) => { - const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; - const template = {schemas: [params.id]}; - const suites = { - strings: [ - ["empty string value", ""], - ["boolean value 'false'", false], - ["number value '1'", 1], - ["complex value", {}] - ], - numbers: [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["decimal value '1.5'", 1.5], - ["complex value", {}] - ], - arrays: [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["number value '1'", 1], - ["complex value", {}], - ["array with an empty string", ["test", ""]] - ] - }; - +const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; +const template = {schemas: [params.id]}; +const suites = { + strings: [ + ["empty string value", ""], + ["boolean value 'false'", false], + ["number value '1'", 1], + ["complex value", {}] + ], + numbers: [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["decimal value '1.5'", 1.5], + ["complex value", {}] + ], + arrays: [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["number value '1'", 1], + ["complex value", {}], + ["array with an empty string", ["test", ""]] + ] +}; + +export const SearchRequestSuite = () => { it("should include static class 'SearchRequest'", () => assert.ok(!!SCIMMY.Messages.SearchRequest, "Static class 'SearchRequest' not defined")); @@ -241,4 +242,4 @@ export let SearchRequestSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources.js b/test/lib/resources.js index e11dca2..e984a4d 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -1,412 +1,424 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; import {SchemaSuite} from "./resources/schema.js"; import {ResourceTypeSuite} from "./resources/resourcetype.js"; import {ServiceProviderConfigSuite} from "./resources/spconfig.js"; import {UserSuite} from "./resources/user.js"; import {GroupSuite} from "./resources/group.js"; -export let ResourcesSuite = (SCIMMY) => { - const ResourcesHooks = { - endpoint: (TargetResource) => (() => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), - "Resource did not implement static member 'endpoint'"); - assert.ok(typeof TargetResource.endpoint === "string", - "Static member 'endpoint' was not a string"); - }), - schema: (TargetResource, implemented = true) => (() => { - if (implemented) { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Resource did not implement static member 'schema'"); - assert.ok(TargetResource.schema.prototype instanceof SCIMMY.Types.Schema, - "Static member 'schema' was not a Schema"); - } else { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Static member 'schema' unexpectedly implemented by resource"); - } - }), - extensions: (TargetResource, implemented = true) => (() => { - if (implemented) { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extensions"), - "Resource did not implement static member 'extensions'"); - assert.ok(Array.isArray(TargetResource.extensions), - "Static member 'extensions' was not an array"); - } else { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extensions"), - "Static member 'extensions' unexpectedly implemented by resource"); - } - }), - extend: (TargetResource, overrides = false) => (() => { - if (!overrides) { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Static method 'extend' unexpectedly overridden by resource"); - } else { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Resource did not override static method 'extend'"); - assert.ok(typeof TargetResource.extend === "function", - "Static method 'extend' was not a function"); - assert.throws(() => TargetResource.extend(), - {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, - "Static method 'extend' did not throw failure"); - } - }), - ingress: (TargetResource, fixtures) => (() => { - if (fixtures) { - let handler = async (res, instance) => { - let {egress} = await fixtures, - target = Object.assign((!!res.id ? egress.find(f => f.id === res.id) : {id: "5"}), - JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined}))); - - if (!egress.includes(target)) egress.push(target); - return target; - }; - - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), - "Resource did not implement static method 'ingress'"); - assert.ok(typeof TargetResource.ingress === "function", - "Static method 'ingress' was not a function"); - assert.strictEqual(TargetResource.ingress(handler), TargetResource, - "Static method 'ingress' did not correctly set ingress handler"); - } else { - assert.throws(() => TargetResource.ingress(), - {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'ingress' unexpectedly implemented by resource"); - } - }), - egress: (TargetResource, fixtures) => (() => { - if (fixtures) { - let handler = async (res) => { - let {egress} = await fixtures, - target = (!!res.id ? egress.find(f => f.id === res.id) : egress); - - if (!target) throw new Error("Not found"); - else return (Array.isArray(target) ? target : [target]); - }; +export const ResourcesHooks = { + endpoint: (TargetResource) => (() => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), + "Resource did not implement static member 'endpoint'"); + assert.ok(typeof TargetResource.endpoint === "string", + "Static member 'endpoint' was not a string"); + }), + schema: (TargetResource, implemented = true) => (() => { + if (implemented) { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Resource did not implement static member 'schema'"); + assert.ok(TargetResource.schema.prototype instanceof SCIMMY.Types.Schema, + "Static member 'schema' was not a Schema"); + } else { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Static member 'schema' unexpectedly implemented by resource"); + } + }), + extensions: (TargetResource, implemented = true) => (() => { + if (implemented) { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extensions"), + "Resource did not implement static member 'extensions'"); + assert.ok(Array.isArray(TargetResource.extensions), + "Static member 'extensions' was not an array"); + } else { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extensions"), + "Static member 'extensions' unexpectedly implemented by resource"); + } + }), + extend: (TargetResource, overrides = false) => (() => { + if (!overrides) { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Static method 'extend' unexpectedly overridden by resource"); + } else { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Resource did not override static method 'extend'"); + assert.ok(typeof TargetResource.extend === "function", + "Static method 'extend' was not a function"); + assert.throws(() => TargetResource.extend(), + {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, + "Static method 'extend' did not throw failure"); + } + }), + ingress: (TargetResource, fixtures) => (() => { + if (fixtures) { + const handler = async (res, instance) => { + const {egress} = await fixtures; + const target = Object.assign( + (!!res.id ? egress.find(f => f.id === res.id) : {id: "5"}), + JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) + ); - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), - "Resource did not implement static method 'egress'"); - assert.ok(typeof TargetResource.egress === "function", - "Static method 'egress' was not a function"); - assert.strictEqual(TargetResource.egress(handler), TargetResource, - "Static method 'egress' did not correctly set egress handler"); - } else { - assert.throws(() => TargetResource.egress(), - {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'egress' unexpectedly implemented by resource"); - } - }), - degress: (TargetResource, fixtures) => (() => { - if (fixtures) { - let handler = async (res) => { - let {egress} = await fixtures, - index = egress.indexOf(egress.find(f => f.id === res.id)); - - if (index < 0) throw new SCIMMY.Types.Error(404, null, `Resource ${res.id} not found`); - else egress.splice(index, 1); - }; + if (!egress.includes(target)) egress.push(target); + return target; + }; + + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), + "Resource did not implement static method 'ingress'"); + assert.ok(typeof TargetResource.ingress === "function", + "Static method 'ingress' was not a function"); + assert.strictEqual(TargetResource.ingress(handler), TargetResource, + "Static method 'ingress' did not correctly set ingress handler"); + } else { + assert.throws(() => TargetResource.ingress(), + {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'ingress' unexpectedly implemented by resource"); + } + }), + egress: (TargetResource, fixtures) => (() => { + if (fixtures) { + const handler = async (res) => { + const {egress} = await fixtures; + const target = (!!res.id ? egress.find(f => f.id === res.id) : egress); - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), - "Resource did not implement static method 'degress'"); - assert.ok(typeof TargetResource.degress === "function", - "Static method 'degress' was not a function"); - assert.strictEqual(TargetResource.degress(handler), TargetResource, - "Static method 'degress' did not correctly set degress handler"); - } else { - assert.throws(() => TargetResource.degress(), - {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'degress' unexpectedly implemented by resource"); - } - }), - basepath: (TargetResource) => (() => { - it("should implement static method 'basepath'", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("basepath"), - "Static method 'basepath' was not implemented by resource"); - assert.ok(typeof TargetResource.basepath === "function", - "Static method 'basepath' was not a function"); - }); + if (!target) throw new Error("Not found"); + else return (Array.isArray(target) ? target : [target]); + }; - it("should only set basepath once, and do nothing if basepath has already been set", () => { - let existing = TargetResource.basepath(), - expected = `/scim${TargetResource.endpoint}`; + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), + "Resource did not implement static method 'egress'"); + assert.ok(typeof TargetResource.egress === "function", + "Static method 'egress' was not a function"); + assert.strictEqual(TargetResource.egress(handler), TargetResource, + "Static method 'egress' did not correctly set egress handler"); + } else { + assert.throws(() => TargetResource.egress(), + {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'egress' unexpectedly implemented by resource"); + } + }), + degress: (TargetResource, fixtures) => (() => { + if (fixtures) { + const handler = async (res) => { + const {egress} = await fixtures; + const index = egress.indexOf(egress.find(f => f.id === res.id)); - TargetResource.basepath("/scim"); - assert.ok(TargetResource.basepath() === (existing ?? expected), - "Static method 'basepath' did not set or ignore resource basepath"); + if (index < 0) throw new SCIMMY.Types.Error(404, null, `Resource ${res.id} not found`); + else egress.splice(index, 1); + }; + + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), + "Resource did not implement static method 'degress'"); + assert.ok(typeof TargetResource.degress === "function", + "Static method 'degress' was not a function"); + assert.strictEqual(TargetResource.degress(handler), TargetResource, + "Static method 'degress' did not correctly set degress handler"); + } else { + assert.throws(() => TargetResource.degress(), + {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'degress' unexpectedly implemented by resource"); + } + }), + basepath: (TargetResource) => (() => { + it("should implement static method 'basepath'", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("basepath"), + "Static method 'basepath' was not implemented by resource"); + assert.ok(typeof TargetResource.basepath === "function", + "Static method 'basepath' was not a function"); + }); + + it("should only set basepath once, and do nothing if basepath has already been set", () => { + const existing = TargetResource.basepath(); + const expected = `/scim${TargetResource.endpoint}`; + + TargetResource.basepath("/scim"); + assert.ok(TargetResource.basepath() === (existing ?? expected), + "Static method 'basepath' did not set or ignore resource basepath"); + + TargetResource.basepath("/test"); + assert.ok(TargetResource.basepath() === (existing ?? expected), + "Static method 'basepath' did not do nothing when basepath was already set"); + }); + }), + construct: (TargetResource, filterable = true) => (() => { + it("should not require arguments at instantiation", () => { + assert.doesNotThrow(() => new TargetResource(), + "Resource did not instantiate without arguments"); + }); + + if (filterable) { + it("should expect query parameters to be an object after instantiation", () => { + const fixtures = [ + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; - TargetResource.basepath("/test"); - assert.ok(TargetResource.basepath() === (existing ?? expected), - "Static method 'basepath' did not do nothing when basepath was already set"); - }); - }), - construct: (TargetResource, filterable = true) => (() => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new TargetResource(), - "Resource did not instantiate without arguments"); + for (let [label, value] of fixtures) { + assert.throws(() => new TargetResource(value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Expected query parameters to be a single complex object value"}, + `Resource did not reject query parameters ${label}`); + } }); - if (filterable) { - it("should expect query parameters to be an object after instantiation", () => { - let fixtures = [ - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource(value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Expected query parameters to be a single complex object value"}, - `Resource did not reject query parameters ${label}`); - } - }); + it("should expect 'id' argument to be a non-empty string, if supplied", () => { + const fixtures = [ + ["null value", null], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; - it("should expect 'id' argument to be a non-empty string, if supplied", () => { - let fixtures = [ - ["null value", null], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - + for (let [label, value] of fixtures) { + assert.throws(() => new TargetResource(value, {}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Expected 'id' parameter to be a non-empty string"}, + `Resource did not reject 'id' parameter ${label}`); + } + }); + + const suites = [ + ["filter", "non-empty"], + ["excludedAttributes", "comma-separated list"], + ["attributes", "comma-separated list"] + ]; + + const fixtures = [ + ["object value", {}], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; + + for (let [prop, type] of suites) { + it(`should expect '${prop}' property of query parameters to be a ${type} string`, () => { for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource(value, {}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Expected 'id' parameter to be a non-empty string"}, - `Resource did not reject 'id' parameter ${label}`); + assert.throws(() => new TargetResource({[prop]: value}), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: `Expected ${prop} to be a ${type} string`}, + `Resource did not reject '${prop}' property of query parameter with ${label}`); } }); - - let suites = [["filter", "non-empty"], ["excludedAttributes", "comma-separated list"], ["attributes", "comma-separated list"]], - fixtures = [ - ["object value", {}], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [prop, type] of suites) { - it(`should expect '${prop}' property of query parameters to be a ${type} string`, () => { - for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource({[prop]: value}), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: `Expected ${prop} to be a ${type} string`}, - `Resource did not reject '${prop}' property of query parameter with ${label}`); - } - }); - } - } else { - it("should not instantiate when a filter has been specified", () => { - assert.throws(() => new TargetResource({filter: "id pr"}), - {name: "SCIMError", status: 403, scimType: null, - message: `${TargetResource.name} does not support retrieval by filter`}, - "Internal resource instantiated when filter was specified"); - }); } - }), - read: (TargetResource, fixtures, listable = true) => (() => { - it("should implement instance method 'read'", () => { - assert.ok("read" in (new TargetResource()), - "Resource did not implement instance method 'read'"); - assert.ok(typeof (new TargetResource()).read === "function", - "Instance method 'read' was not a function"); + } else { + it("should not instantiate when a filter has been specified", () => { + assert.throws(() => new TargetResource({filter: "id pr"}), + {name: "SCIMError", status: 403, scimType: null, + message: `${TargetResource.name} does not support retrieval by filter`}, + "Internal resource instantiated when filter was specified"); }); - - if (listable) { - it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { - let {egress: expected} = await fixtures, - result = await (new TargetResource()).read(), - resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ - ...r, schemas: undefined, meta: undefined, attributes: undefined - }))); - - assert.ok(result instanceof SCIMMY.Messages.ListResponse, - "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); - assert.deepStrictEqual(resources, expected, - "Instance method 'read' did not return a ListResponse containing all resources from fixture"); - }); - - it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { - let {egress: [expected]} = await fixtures, - actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource(expected.id)).read(), - schemas: undefined, meta: undefined, attributes: undefined - })); - - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource instance by ID"); - }); + } + }), + read: (TargetResource, fixtures, listable = true) => (() => { + it("should implement instance method 'read'", () => { + assert.ok("read" in (new TargetResource()), + "Resource did not implement instance method 'read'"); + assert.ok(typeof (new TargetResource()).read === "function", + "Instance method 'read' was not a function"); + }); + + if (listable) { + it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { + const {egress: expected} = await fixtures; + const result = await (new TargetResource()).read(); + const resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ + ...r, schemas: undefined, meta: undefined, attributes: undefined + }))); - it("should expect a resource with supplied ID to exist", async () => { - await assert.rejects(() => new TargetResource("10").read(), - {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, - "Instance method 'read' did not expect requested resource to exist"); - }); - } else { - it("should return the requested resource without sugar-coating", async () => { - let {egress: expected} = await fixtures, - actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource()).read(), schemas: undefined, meta: undefined - })); - - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource without sugar-coating"); - }); - } - }), - write: (TargetResource, fixtures) => (() => { - if (fixtures) { - it("should implement instance method 'write'", () => { - assert.ok("write" in (new TargetResource()), - "Resource did not implement instance method 'write'"); - assert.ok(typeof (new TargetResource()).write === "function", - "Instance method 'write' was not a function"); - }); + assert.ok(result instanceof SCIMMY.Messages.ListResponse, + "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); + assert.deepStrictEqual(resources, expected, + "Instance method 'read' did not return a ListResponse containing all resources from fixture"); + }); + + it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { + const {egress: [expected]} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource(expected.id)).read(), + schemas: undefined, meta: undefined, attributes: undefined + })); - it("should expect 'instance' argument to be an object", async () => { - let suites = [["POST", "new resources"], ["PUT", "existing resources", "1"]], - fixtures = [ - ["string value 'a string'", "a string"], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [method, name, value] of suites) { - let resource = new TargetResource(value); - - await assert.rejects(() => resource.write(), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `Missing request body payload for ${method} operation`}, - `Instance method 'write' did not expect 'instance' parameter to exist for ${name}`); - - for (let [label, value] of fixtures) { - await assert.rejects(() => resource.write(value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `Operation ${method} expected request body payload to be single complex value`}, - `Instance method 'write' did not reject 'instance' parameter ${label} for ${name}`); - } - } - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource instance by ID"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + await assert.rejects(() => new TargetResource("10").read(), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'read' did not expect requested resource to exist"); + }); + } else { + it("should return the requested resource without sugar-coating", async () => { + const {egress: expected} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource()).read(), schemas: undefined, meta: undefined + })); - it("should call ingress to create new resources when resource instantiated without ID", async () => { - let {ingress: source} = await fixtures, - result = await (new TargetResource()).write(source); - - assert.deepStrictEqual(await (new TargetResource(result.id)).read(), result, - "Instance method 'write' did not create new resource"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource without sugar-coating"); + }); + } + }), + write: (TargetResource, fixtures) => (() => { + if (fixtures) { + it("should implement instance method 'write'", () => { + assert.ok("write" in (new TargetResource()), + "Resource did not implement instance method 'write'"); + assert.ok(typeof (new TargetResource()).write === "function", + "Instance method 'write' was not a function"); + }); + + it("should expect 'instance' argument to be an object", async () => { + const suites = [ + ["POST", "new resources"], + ["PUT", "existing resources", "1"] + ]; - it("should call ingress to update existing resources when resource instantiated with ID", async () => { - let {egress: [fixture]} = await fixtures, - [, target] = Object.keys(fixture), - instance = {...fixture, [target]: "TEST"}, - expected = await (new TargetResource(fixture.id)).write(instance), - actual = await (new TargetResource(fixture.id)).read(); - - assert.deepStrictEqual(actual, expected, - "Instance method 'write' did not update existing resource"); - }); - } else { - assert.throws(() => new TargetResource().write(), - {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'write' unexpectedly implemented by resource"); - } - }), - patch: (TargetResource, fixtures) => (() => { - if (fixtures) { - it("should implement instance method 'patch'", () => { - assert.ok("patch" in (new TargetResource()), - "Resource did not implement instance method 'patch'"); - assert.ok(typeof (new TargetResource()).patch === "function", - "Instance method 'patch' was not a function"); - }); + const fixtures = [ + ["string value 'a string'", "a string"], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; - it("should expect 'message' argument to be an object", async () => { - let fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["array value", []] - ]; + for (let [method, name, value] of suites) { + const resource = new TargetResource(value); - await assert.rejects(() => new TargetResource().patch(), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Missing message body from PatchOp request"}, - "Instance method 'patch' did not expect 'message' parameter to exist"); + await assert.rejects(() => resource.write(), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `Missing request body payload for ${method} operation`}, + `Instance method 'write' did not expect 'instance' parameter to exist for ${name}`); for (let [label, value] of fixtures) { - await assert.rejects(() => new TargetResource().patch(value), + await assert.rejects(() => resource.write(value), {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "PatchOp request expected message body to be single complex value"}, - `Instance method 'patch' did not reject 'message' parameter ${label}`); + message: `Operation ${method} expected request body payload to be single complex value`}, + `Instance method 'write' did not reject 'instance' parameter ${label} for ${name}`); } - }); + } + }); + + it("should call ingress to create new resources when resource instantiated without ID", async () => { + const {ingress: source} = await fixtures; + const result = await (new TargetResource()).write(source); - it("should return nothing when applied PatchOp does not modify resource", async () => { - let {egress: [fixture]} = await fixtures, - [, target] = Object.keys(fixture), - result = await (new TargetResource(fixture.id)).patch({ - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", path: target, value: "TEST"}] - }); - - assert.deepStrictEqual(result, undefined, - "Instance method 'patch' did not return nothing when resource was not modified"); - }); + assert.deepStrictEqual(await (new TargetResource(result.id)).read(), result, + "Instance method 'write' did not create new resource"); + }); + + it("should call ingress to update existing resources when resource instantiated with ID", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const instance = {...fixture, [target]: "TEST"}; + const expected = await (new TargetResource(fixture.id)).write(instance); + const actual = await (new TargetResource(fixture.id)).read(); - it("should return the full resource when applied PatchOp modifies resource", async () => { - let {egress: [fixture]} = await fixtures, - [, target] = Object.keys(fixture), - expected = {...fixture, [target]: "Test"}, - actual = await (new TargetResource(fixture.id)).patch({ - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", path: target, value: "Test"}] - }); - - assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, - "Instance method 'patch' did not return the full resource when resource was modified"); - }); - } else { - assert.throws(() => new TargetResource().patch(), - {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'patch' unexpectedly implemented by resource"); - } - }), - dispose: (TargetResource, fixtures) => (() => { - if (fixtures) { - it("should implement instance method 'dispose'", () => { - assert.ok("dispose" in (new TargetResource()), - "Resource did not implement instance method 'dispose'"); - assert.ok(typeof (new TargetResource()).dispose === "function", - "Instance method 'dispose' was not a function"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'write' did not update existing resource"); + }); + } else { + assert.throws(() => new TargetResource().write(), + {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'write' unexpectedly implemented by resource"); + } + }), + patch: (TargetResource, fixtures) => (() => { + if (fixtures) { + it("should implement instance method 'patch'", () => { + assert.ok("patch" in (new TargetResource()), + "Resource did not implement instance method 'patch'"); + assert.ok(typeof (new TargetResource()).patch === "function", + "Instance method 'patch' was not a function"); + }); + + it("should expect 'message' argument to be an object", async () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["array value", []] + ]; - it("should expect resource instances to have 'id' property", async () => { - await assert.rejects(() => new TargetResource().dispose(), - {name: "SCIMError", status: 404, scimType: null, - message: "DELETE operation must target a specific resource"}, - "Instance method 'dispose' did not expect resource instance to have 'id' property"); - }); + await assert.rejects(() => new TargetResource().patch(), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Missing message body from PatchOp request"}, + "Instance method 'patch' did not expect 'message' parameter to exist"); - it("should call degress to delete a resource instance", async () => { - await assert.doesNotReject(() => new TargetResource("5").dispose(), - "Instance method 'dispose' rejected a valid degress request"); - await assert.rejects(() => new TargetResource("5").dispose(), - {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, - "Instance method 'dispose' did not delete the given resource"); + for (let [label, value] of fixtures) { + await assert.rejects(() => new TargetResource().patch(value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "PatchOp request expected message body to be single complex value"}, + `Instance method 'patch' did not reject 'message' parameter ${label}`); + } + }); + + it("should return nothing when applied PatchOp does not modify resource", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const result = await (new TargetResource(fixture.id)).patch({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", path: target, value: "TEST"}] }); - it("should expect a resource with supplied ID to exist", async () => { - await assert.rejects(() => new TargetResource("5").dispose(), - {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, - "Instance method 'dispose' did not expect requested resource to exist"); + assert.deepStrictEqual(result, undefined, + "Instance method 'patch' did not return nothing when resource was not modified"); + }); + + it("should return the full resource when applied PatchOp modifies resource", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const expected = {...fixture, [target]: "Test"}; + const actual = await (new TargetResource(fixture.id)).patch({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", path: target, value: "Test"}] }); - } else { - assert.throws(() => new TargetResource().dispose(), - {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'dispose' unexpectedly implemented by resource"); - } - }) - }; - + + assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, + "Instance method 'patch' did not return the full resource when resource was modified"); + }); + } else { + assert.throws(() => new TargetResource().patch(), + {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'patch' unexpectedly implemented by resource"); + } + }), + dispose: (TargetResource, fixtures) => (() => { + if (fixtures) { + it("should implement instance method 'dispose'", () => { + assert.ok("dispose" in (new TargetResource()), + "Resource did not implement instance method 'dispose'"); + assert.ok(typeof (new TargetResource()).dispose === "function", + "Instance method 'dispose' was not a function"); + }); + + it("should expect resource instances to have 'id' property", async () => { + await assert.rejects(() => new TargetResource().dispose(), + {name: "SCIMError", status: 404, scimType: null, + message: "DELETE operation must target a specific resource"}, + "Instance method 'dispose' did not expect resource instance to have 'id' property"); + }); + + it("should call degress to delete a resource instance", async () => { + await assert.doesNotReject(() => new TargetResource("5").dispose(), + "Instance method 'dispose' rejected a valid degress request"); + await assert.rejects(() => new TargetResource("5").dispose(), + {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, + "Instance method 'dispose' did not delete the given resource"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + await assert.rejects(() => new TargetResource("5").dispose(), + {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, + "Instance method 'dispose' did not expect requested resource to exist"); + }); + } else { + assert.throws(() => new TargetResource().dispose(), + {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'dispose' unexpectedly implemented by resource"); + } + }) +}; + +export const ResourcesSuite = () => { it("should include static class 'Resources'", () => assert.ok(!!SCIMMY.Resources, "Static class 'Resources' not defined")); @@ -504,10 +516,10 @@ export let ResourcesSuite = (SCIMMY) => { }); }); - SchemaSuite(SCIMMY, ResourcesHooks); - ResourceTypeSuite(SCIMMY, ResourcesHooks); - ServiceProviderConfigSuite(SCIMMY, ResourcesHooks); - UserSuite(SCIMMY, ResourcesHooks); - GroupSuite(SCIMMY, ResourcesHooks); + SchemaSuite(); + ResourceTypeSuite(); + ServiceProviderConfigSuite(); + UserSuite(); + GroupSuite(); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources/group.js b/test/lib/resources/group.js index e8e9f1a..ee237f8 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {ResourcesHooks} from "../resources.js"; -export let GroupSuite = (SCIMMY, ResourcesHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); + +export const GroupSuite = () => { it("should include static class 'Group'", () => assert.ok(!!SCIMMY.Resources.Group, "Static class 'Group' not defined")); @@ -26,4 +28,4 @@ export let GroupSuite = (SCIMMY, ResourcesHooks) => { describe("#patch()", ResourcesHooks.patch(SCIMMY.Resources.Group, fixtures)); describe("#dispose()", ResourcesHooks.dispose(SCIMMY.Resources.Group, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources/resourcetype.js b/test/lib/resources/resourcetype.js index 38c734e..0d81805 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {ResourcesHooks} from "../resources.js"; -export let ResourceTypeSuite = (SCIMMY, ResourcesHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); + +export const ResourceTypeSuite = () => { it("should include static class 'ResourceType'", () => assert.ok(!!SCIMMY.Resources.ResourceType, "Static class 'ResourceType' not defined")); @@ -26,4 +28,4 @@ export let ResourceTypeSuite = (SCIMMY, ResourcesHooks) => { describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.ResourceType, false)); describe("#read()", ResourcesHooks.read(SCIMMY.Resources.ResourceType, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources/schema.js b/test/lib/resources/schema.js index 436d313..987805b 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {ResourcesHooks} from "../resources.js"; -export let SchemaSuite = (SCIMMY, ResourcesHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); + +export const SchemaSuite = () => { it("should include static class 'Schema'", () => assert.ok(!!SCIMMY.Resources.Schema, "Static class 'Schema' not defined")); @@ -26,4 +28,4 @@ export let SchemaSuite = (SCIMMY, ResourcesHooks) => { describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.Schema, false)); describe("#read()", ResourcesHooks.read(SCIMMY.Resources.Schema, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources/spconfig.js b/test/lib/resources/spconfig.js index b00366f..8d244a0 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {ResourcesHooks} from "../resources.js"; -export let ServiceProviderConfigSuite = (SCIMMY, ResourcesHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); + +export const ServiceProviderConfigSuite = () => { it("should include static class 'ServiceProviderConfig'", () => assert.ok(!!SCIMMY.Resources.ServiceProviderConfig, "Static class 'ServiceProviderConfig' not defined")); @@ -26,4 +28,4 @@ export let ServiceProviderConfigSuite = (SCIMMY, ResourcesHooks) => { describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.ServiceProviderConfig, false)); describe("#read()", ResourcesHooks.read(SCIMMY.Resources.ServiceProviderConfig, fixtures, false)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/resources/user.js b/test/lib/resources/user.js index a8647ec..d660741 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {ResourcesHooks} from "../resources.js"; -export let UserSuite = (SCIMMY, ResourcesHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); + +export const UserSuite = () => { it("should include static class 'User'", () => assert.ok(!!SCIMMY.Resources.User, "Static class 'User' not defined")); @@ -26,4 +28,4 @@ export let UserSuite = (SCIMMY, ResourcesHooks) => { describe("#patch()", ResourcesHooks.patch(SCIMMY.Resources.User, fixtures)); describe("#dispose()", ResourcesHooks.dispose(SCIMMY.Resources.User, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas.js b/test/lib/schemas.js index c30551e..65371e6 100644 --- a/test/lib/schemas.js +++ b/test/lib/schemas.js @@ -1,126 +1,127 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; import {ResourceTypeSuite} from "./schemas/resourcetype.js"; import {ServiceProviderConfigSuite} from "./schemas/spconfig.js"; import {UserSuite} from "./schemas/user.js"; import {GroupSuite} from "./schemas/group.js"; import {EnterpriseUserSuite} from "./schemas/enterpriseuser.js"; -export let SchemasSuite = (SCIMMY) => { - const SchemasHooks = { - construct: (TargetSchema, fixtures) => (() => { - it("should require 'resource' parameter to be an object at instantiation", () => { - assert.throws(() => new TargetSchema(), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instance did not expect 'resource' parameter to be defined"); - assert.throws(() => new TargetSchema("a string"), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instantiation did not fail with 'resource' parameter string value 'a string'"); - assert.throws(() => new TargetSchema([]), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instantiation did not fail with 'resource' parameter array value"); - }); - - it("should validate 'schemas' property of 'resource' parameter if it is defined", () => { - try { - // Add an empty required extension - TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"), true); - - assert.throws(() => new TargetSchema({schemas: ["a string"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "The request body supplied a schema type that is incompatible with this resource"}, - "Schema instance did not validate 'schemas' property of 'resource' parameter"); - assert.throws(() => new TargetSchema({schemas: [TargetSchema.definition.id]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Test' required by this resource type"}, - "Schema instance did not validate required extensions in 'schemas' property of 'resource' parameter"); - } finally { - // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); - } - }); - - it("should define getters and setters for all attributes in the schema definition", async () => { - let {definition, constructor = {}} = await fixtures, - attributes = definition.attributes.map(a => a.name), - instance = new TargetSchema(constructor); +export const SchemasHooks = { + construct: (TargetSchema, fixtures) => (() => { + it("should require 'resource' parameter to be an object at instantiation", () => { + assert.throws(() => new TargetSchema(), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instance did not expect 'resource' parameter to be defined"); + assert.throws(() => new TargetSchema("a string"), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instantiation did not fail with 'resource' parameter string value 'a string'"); + assert.throws(() => new TargetSchema([]), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instantiation did not fail with 'resource' parameter array value"); + }); + + it("should validate 'schemas' property of 'resource' parameter if it is defined", () => { + try { + // Add an empty required extension + TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"), true); - for (let attrib of attributes) { - assert.ok(attrib in instance, - `Schema instance did not define member '${attrib}'`); - assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).get === "function", - `Schema instance member '${attrib}' was not defined with a 'get' method`); - assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).set === "function", - `Schema instance member '${attrib}' was not defined with a 'set' method`); - } - }); - - it("should include lower-case attribute name property accessor aliases", async () => { - let {constructor = {}} = await fixtures, - instance = new TargetSchema(constructor), - [key, value] = Object.entries(constructor).shift(); + assert.throws(() => new TargetSchema({schemas: ["a string"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "The request body supplied a schema type that is incompatible with this resource"}, + "Schema instance did not validate 'schemas' property of 'resource' parameter"); + assert.throws(() => new TargetSchema({schemas: [TargetSchema.definition.id]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Test' required by this resource type"}, + "Schema instance did not validate required extensions in 'schemas' property of 'resource' parameter"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + } + }); + + it("should define getters and setters for all attributes in the schema definition", async () => { + const {definition, constructor = {}} = await fixtures; + const attributes = definition.attributes.map(a => a.name); + const instance = new TargetSchema(constructor); + + for (let attrib of attributes) { + assert.ok(attrib in instance, + `Schema instance did not define member '${attrib}'`); + assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).get === "function", + `Schema instance member '${attrib}' was not defined with a 'get' method`); + assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).set === "function", + `Schema instance member '${attrib}' was not defined with a 'set' method`); + } + }); + + it("should include lower-case attribute name property accessor aliases", async () => { + const {constructor = {}} = await fixtures; + const instance = new TargetSchema(constructor); + const [key, value] = Object.entries(constructor).shift(); + + try { + instance[key.toLowerCase()] = value.toUpperCase(); + assert.strictEqual(instance[key], value.toUpperCase(), + "Schema instance did not include lower-case attribute aliases"); + } catch (ex) { + if (ex.scimType !== "mutability") throw ex; + } + }); + + it("should include extension schema attribute property accessor aliases", async () => { + try { + // Add an extension with one attribute + TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test", "", [ + new SCIMMY.Types.Attribute("string", "testValue") + ])); - try { - instance[key.toLowerCase()] = value.toUpperCase(); - assert.strictEqual(instance[key], value.toUpperCase(), - "Schema instance did not include lower-case attribute aliases"); - } catch (ex) { - if (ex.scimType !== "mutability") throw ex; - } - }); - - it("should include extension schema attribute property accessor aliases", async () => { - try { - // Add an extension with one attribute - TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test", "", [ - new SCIMMY.Types.Attribute("string", "testValue") - ])); - - // Construct an instance to test against - let {constructor = {}} = await fixtures, - target = "urn:ietf:params:scim:schemas:Test:testValue", - instance = new TargetSchema(constructor); - - instance[target] = "a string"; - assert.strictEqual(instance[target], "a string", - "Schema instance did not include schema extension attribute aliases"); - instance[target.toLowerCase()] = "another string"; - assert.strictEqual(instance[target], "another string", - "Schema instance did not include lower-case schema extension attribute aliases"); - } finally { - // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); - } - }); - - it("should be frozen after instantiation", async () => { - let {constructor = {}} = await fixtures, - instance = new TargetSchema(constructor); + // Construct an instance to test against + const {constructor = {}} = await fixtures; + const target = "urn:ietf:params:scim:schemas:Test:testValue"; + const instance = new TargetSchema(constructor); - assert.throws(() => instance.test = true, - {name: "TypeError", message: "Cannot add property test, object is not extensible"}, - "Schema was extensible after instantiation"); - assert.throws(() => delete instance.meta, - {name: "TypeError", message: `Cannot delete property 'meta' of #<${instance.constructor.name}>`}, - "Schema was not sealed after instantiation"); - }); - }), - definition: (TargetSchema, fixtures) => (() => { - it("should have static member 'definition' that is an instance of SchemaDefinition", () => { - assert.ok("definition" in TargetSchema, - "Static member 'definition' not defined"); - assert.ok(TargetSchema.definition instanceof SCIMMY.Types.SchemaDefinition, - "Static member 'definition' was not an instance of SchemaDefinition"); - }); + instance[target] = "a string"; + assert.strictEqual(instance[target], "a string", + "Schema instance did not include schema extension attribute aliases"); + instance[target.toLowerCase()] = "another string"; + assert.strictEqual(instance[target], "another string", + "Schema instance did not include lower-case schema extension attribute aliases"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + } + }); + + it("should be frozen after instantiation", async () => { + const {constructor = {}} = await fixtures; + const instance = new TargetSchema(constructor); + + assert.throws(() => instance.test = true, + {name: "TypeError", message: "Cannot add property test, object is not extensible"}, + "Schema was extensible after instantiation"); + assert.throws(() => delete instance.meta, + {name: "TypeError", message: `Cannot delete property 'meta' of #<${instance.constructor.name}>`}, + "Schema was not sealed after instantiation"); + }); + }), + definition: (TargetSchema, fixtures) => (() => { + it("should have static member 'definition' that is an instance of SchemaDefinition", () => { + assert.ok("definition" in TargetSchema, + "Static member 'definition' not defined"); + assert.ok(TargetSchema.definition instanceof SCIMMY.Types.SchemaDefinition, + "Static member 'definition' was not an instance of SchemaDefinition"); + }); + + it("should produce definition object that matches sample schemas defined in RFC7643", async () => { + const {definition} = await fixtures; - it("should produce definition object that matches sample schemas defined in RFC7643", async () => { - let {definition} = await fixtures; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(TargetSchema.definition.describe("/Schemas"))), definition, - "Definition did not match sample schema defined in RFC7643"); - }); - }) - }; - + assert.deepStrictEqual(JSON.parse(JSON.stringify(TargetSchema.definition.describe("/Schemas"))), definition, + "Definition did not match sample schema defined in RFC7643"); + }); + }) +}; + +export const SchemasSuite = () => { it("should include static class 'Schemas'", () => assert.ok(!!SCIMMY.Schemas, "Static class 'Schemas' not defined")); @@ -196,7 +197,7 @@ export let SchemasSuite = (SCIMMY) => { }); it("should find nested schema extension definition instances", () => { - let extension = new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); + const extension = new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); try { SCIMMY.Schemas.User.extend(extension); @@ -208,10 +209,10 @@ export let SchemasSuite = (SCIMMY) => { }); }); - ResourceTypeSuite(SCIMMY, SchemasHooks); - ServiceProviderConfigSuite(SCIMMY, SchemasHooks); - UserSuite(SCIMMY, SchemasHooks); - GroupSuite(SCIMMY, SchemasHooks); - EnterpriseUserSuite(SCIMMY, SchemasHooks); + ResourceTypeSuite(); + ServiceProviderConfigSuite(); + UserSuite(); + GroupSuite(); + EnterpriseUserSuite(); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas/enterpriseuser.js b/test/lib/schemas/enterpriseuser.js index 2c5e37b..2b7b1ad 100644 --- a/test/lib/schemas/enterpriseuser.js +++ b/test/lib/schemas/enterpriseuser.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {SchemasHooks} from "../schemas.js"; -export let EnterpriseUserSuite = (SCIMMY, SchemasHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8").then((f) => JSON.parse(f)); + +export const EnterpriseUserSuite = () => { it("should include static class 'EnterpriseUser'", () => assert.ok(!!SCIMMY.Schemas.EnterpriseUser, "Static class 'EnterpriseUser' not defined")); @@ -14,4 +16,4 @@ export let EnterpriseUserSuite = (SCIMMY, SchemasHooks) => { describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.EnterpriseUser, fixtures)); describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.EnterpriseUser, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas/group.js b/test/lib/schemas/group.js index ad0ffbd..feb22f0 100644 --- a/test/lib/schemas/group.js +++ b/test/lib/schemas/group.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {SchemasHooks} from "../schemas.js"; -export let GroupSuite = (SCIMMY, SchemasHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); + +export const GroupSuite = () => { it("should include static class 'Group'", () => assert.ok(!!SCIMMY.Schemas.Group, "Static class 'Group' not defined")); @@ -14,4 +16,4 @@ export let GroupSuite = (SCIMMY, SchemasHooks) => { describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.Group, fixtures)); describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.Group, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas/resourcetype.js b/test/lib/schemas/resourcetype.js index a01b7e9..d49eabc 100644 --- a/test/lib/schemas/resourcetype.js +++ b/test/lib/schemas/resourcetype.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {SchemasHooks} from "../schemas.js"; -export let ResourceTypeSuite = (SCIMMY, SchemasHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); + +export const ResourceTypeSuite = () => { it("should include static class 'ResourceType'", () => assert.ok(!!SCIMMY.Schemas.ResourceType, "Static class 'ResourceType' not defined")); @@ -14,4 +16,4 @@ export let ResourceTypeSuite = (SCIMMY, SchemasHooks) => { describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.ResourceType, fixtures)); describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.ResourceType, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas/spconfig.js b/test/lib/schemas/spconfig.js index 7cc154a..734b496 100644 --- a/test/lib/schemas/spconfig.js +++ b/test/lib/schemas/spconfig.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {SchemasHooks} from "../schemas.js"; -export let ServiceProviderConfigSuite = (SCIMMY, SchemasHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); + +export const ServiceProviderConfigSuite = () => { it("should include static class 'ServiceProviderConfig'", () => assert.ok(!!SCIMMY.Schemas.ServiceProviderConfig, "Static class 'ServiceProviderConfig' not defined")); @@ -14,4 +16,4 @@ export let ServiceProviderConfigSuite = (SCIMMY, SchemasHooks) => { describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.ServiceProviderConfig, fixtures)); describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.ServiceProviderConfig, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/schemas/user.js b/test/lib/schemas/user.js index 0641b52..505d3f7 100644 --- a/test/lib/schemas/user.js +++ b/test/lib/schemas/user.js @@ -2,11 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; +import {SchemasHooks} from "../schemas.js"; -export let UserSuite = (SCIMMY, SchemasHooks) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); + +export const UserSuite = () => { it("should include static class 'User'", () => assert.ok(!!SCIMMY.Schemas.User, "Static class 'User' not defined")); @@ -14,4 +16,4 @@ export let UserSuite = (SCIMMY, SchemasHooks) => { describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.User, fixtures)); describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.User, fixtures)); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types.js b/test/lib/types.js index cd8b1c4..c978445 100644 --- a/test/lib/types.js +++ b/test/lib/types.js @@ -1,4 +1,5 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; import {AttributeSuite} from "./types/attribute.js"; import {SchemaDefinitionSuite} from "./types/definition.js"; import {FilterSuite} from "./types/filter.js"; @@ -6,16 +7,16 @@ import {ErrorSuite} from "./types/error.js"; import {SchemaSuite} from "./types/schema.js"; import {ResourceSuite} from "./types/resource.js"; -export let TypesSuite = (SCIMMY) => { +export const TypesSuite = () => { it("should include static class 'Types'", () => assert.ok(!!SCIMMY.Types, "Static class 'Types' not defined")); describe("SCIMMY.Types", () => { - AttributeSuite(SCIMMY); - SchemaDefinitionSuite(SCIMMY); - FilterSuite(SCIMMY); - ErrorSuite(SCIMMY); - SchemaSuite(SCIMMY); - ResourceSuite(SCIMMY); + AttributeSuite(); + SchemaDefinitionSuite(); + FilterSuite(); + ErrorSuite(); + SchemaSuite(); + ResourceSuite(); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index de89ef0..d3feaca 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -2,21 +2,39 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export function instantiateFromFixture(SCIMMY, fixture) { - let {type, name, mutability: m, uniqueness: u, subAttributes = [], ...config} = fixture; +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./attribute.json"), "utf8").then((f) => JSON.parse(f)); + +export function instantiateFromFixture(fixture) { + const {type, name, mutability: m, uniqueness: u, subAttributes = [], ...config} = fixture; return new SCIMMY.Types.Attribute( type, name, {...(m !== undefined ? {mutable: m} : {}), ...(u !== null ? {uniqueness: !u ? false : u} : {}), ...config}, - subAttributes.map((a) => instantiateFromFixture(SCIMMY, a)) + subAttributes.map(instantiateFromFixture) ); } -export let AttributeSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./attribute.json"), "utf8").then((f) => JSON.parse(f)); +// Run valid and invalid fixtures for different attribute types +function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, assertion}) { + const attribute = new SCIMMY.Types.Attribute(type, "test", {...config, multiValued: multiValued}); + const target = (multiValued ? attribute.coerce([]) : null); + + for (let [label, value] of valid) { + assert.doesNotThrow(() => (multiValued ? target.push(value) : attribute.coerce(value)), + `Instance method 'coerce' rejected ${label} when attribute type was ${type}`); + } - it("should include static class 'Attribute'", () => + for (let [label, actual, value] of invalid) { + assert.throws(() => (multiValued ? target.push(value) : attribute.coerce(value)), + {name: "TypeError", message: (typeof assertion === "function" ? assertion(actual) : `Attribute 'test' expected value type '${type}' but found type '${actual}'`)}, + `Instance method 'coerce' did not reject ${label} when attribute type was ${type}`); + } +} + +export const AttributeSuite = () => { + it("should include static class 'Attribute'", () => assert.ok(!!SCIMMY.Types.Attribute, "Static class 'Attribute' not defined")); describe("SCIMMY.Types.Attribute", () => { @@ -33,8 +51,8 @@ export let AttributeSuite = (SCIMMY) => { assert.throws(() => new SCIMMY.Types.Attribute("string"), {name: "TypeError", message: "Required parameter 'name' missing from Attribute instantiation"}, "Attribute instantiated without 'name' argument"); - - let invalidNames = [ + + const invalidNames = [ [".", "invalid.name"], ["@", "invalid@name"], ["=", "invalid=name"], @@ -85,7 +103,7 @@ export let AttributeSuite = (SCIMMY) => { } it("should be frozen after instantiation", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test"); + const attribute = new SCIMMY.Types.Attribute("string", "test"); assert.throws(() => attribute.test = true, {name: "TypeError", message: "Cannot add property test, object is not extensible"}, @@ -105,10 +123,10 @@ export let AttributeSuite = (SCIMMY) => { }); it("should produce valid SCIM attribute definition objects", async () => { - let {toJSON: suite} = await fixtures; + const {toJSON: suite} = await fixtures; for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, fixture); + const attribute = instantiateFromFixture(fixture); assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute)), fixture, `Attribute 'toJSON' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM attribute definition object`); @@ -123,10 +141,10 @@ export let AttributeSuite = (SCIMMY) => { }); it("should do nothing without arguments", async () => { - let {truncate: suite} = await fixtures; + const {truncate: suite} = await fixtures; for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, fixture); + const attribute = instantiateFromFixture(fixture); assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate())), fixture, `Attribute 'truncate' fixture #${suite.indexOf(fixture)+1} modified attribute without arguments`); @@ -134,21 +152,21 @@ export let AttributeSuite = (SCIMMY) => { }); it("should do nothing when type is not 'complex'", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test"), - before = JSON.parse(JSON.stringify(attribute)), - after = JSON.parse(JSON.stringify(attribute.truncate())); + const attribute = new SCIMMY.Types.Attribute("string", "test"); + const before = JSON.parse(JSON.stringify(attribute)); + const after = JSON.parse(JSON.stringify(attribute.truncate())); assert.deepStrictEqual(after, before, "Instance method 'truncate' modified non-complex attribute"); }); it("should remove specified sub-attribute from 'subAttributes' collection", async () => { - let {truncate: suite} = await fixtures; + const {truncate: suite} = await fixtures; for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, fixture), - comparison = {...fixture, subAttributes: [...fixture.subAttributes ?? []]}, - target = comparison.subAttributes.shift()?.name; + const attribute = instantiateFromFixture(fixture); + const comparison = {...fixture, subAttributes: [...fixture.subAttributes ?? []]}; + const target = comparison.subAttributes.shift()?.name; assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate(target))), comparison, `Attribute 'truncate' fixture #${suite.indexOf(fixture) + 1} did not remove specified sub-attribute '${target}'`); @@ -162,23 +180,6 @@ export let AttributeSuite = (SCIMMY) => { "Instance method 'coerce' not defined"); }); - // Run valid and invalid fixtures for different attribute types - function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, assertion}) { - let attribute = new SCIMMY.Types.Attribute(type, "test", {...config, multiValued: multiValued}), - target = (multiValued ? attribute.coerce([]) : null); - - for (let [label, value] of valid) { - assert.doesNotThrow(() => (multiValued ? target.push(value) : attribute.coerce(value)), - `Instance method 'coerce' rejected ${label} when attribute type was ${type}`); - } - - for (let [label, actual, value] of invalid) { - assert.throws(() => (multiValued ? target.push(value) : attribute.coerce(value)), - {name: "TypeError", message: (typeof assertion === "function" ? assertion(actual) : `Attribute 'test' expected value type '${type}' but found type '${actual}'`)}, - `Instance method 'coerce' did not reject ${label} when attribute type was ${type}`); - } - } - it("should expect required attributes to have a value", () => { for (let type of ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"]) { assert.throws(() => new SCIMMY.Types.Attribute(type, "test", {required: true}).coerce(), @@ -194,7 +195,7 @@ export let AttributeSuite = (SCIMMY) => { }); it("should expect value to be an array when attribute is multi-valued", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true}); + const attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true}); assert.doesNotThrow(() => attribute.coerce(), "Instance method 'coerce' rejected empty value when attribute was not required"); @@ -209,7 +210,7 @@ export let AttributeSuite = (SCIMMY) => { }); it("should expect value to be singular when attribute is not multi-valued", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test"); + const attribute = new SCIMMY.Types.Attribute("string", "test"); assert.doesNotThrow(() => attribute.coerce(), "Instance method 'coerce' rejected empty value when attribute was not required"); @@ -222,7 +223,7 @@ export let AttributeSuite = (SCIMMY) => { }); it("should expect value to be canonical when attribute specifies canonicalValues characteristic", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test", {canonicalValues: ["Test"]}); + const attribute = new SCIMMY.Types.Attribute("string", "test", {canonicalValues: ["Test"]}); assert.doesNotThrow(() => attribute.coerce(), "Instance method 'coerce' rejected empty non-canonical value"); @@ -234,8 +235,8 @@ export let AttributeSuite = (SCIMMY) => { }); it("should expect all values to be canonical when attribute is multi-valued and specifies canonicalValues characteristic", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true, canonicalValues: ["Test"]}), - target = attribute.coerce([]); + const attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true, canonicalValues: ["Test"]}); + const target = attribute.coerce([]); assert.throws(() => attribute.coerce(["a string"]), {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, @@ -250,114 +251,134 @@ export let AttributeSuite = (SCIMMY) => { "Instance method 'coerce' did not reject addition of non-canonical value 'a string' to coerced collection"); }); - it("should expect value to be a string when attribute type is 'string'", () => typedCoercion("string", { - valid: [["string value 'a string'", "a string"]], - invalid: [ - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect value to be a string when attribute type is 'string'", () => ( + typedCoercion("string", { + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect all values to be strings when attribute is multi-valued and type is 'string'", () => typedCoercion("string", { - multiValued: true, - valid: [["string value 'a string'", "a string"]], - invalid: [ - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect all values to be strings when attribute is multi-valued and type is 'string'", () => ( + typedCoercion("string", { + multiValued: true, + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect value to be either true or false when attribute type is 'boolean'", () => typedCoercion("boolean", { - valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect value to be either true or false when attribute type is 'boolean'", () => ( + typedCoercion("boolean", { + valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect all values to be either true or false when attribute is multi-valued and type is 'boolean'", () => typedCoercion("boolean", { - multiValued: true, - valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect all values to be either true or false when attribute is multi-valued and type is 'boolean'", () => ( + typedCoercion("boolean", { + multiValued: true, + valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect value to be a decimal number when attribute type is 'decimal'", () => typedCoercion("decimal", { - valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["integer value '1'", "integer", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect value to be a decimal number when attribute type is 'decimal'", () => ( + typedCoercion("decimal", { + valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["integer value '1'", "integer", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect all values to be decimal numbers when attribute is multi-valued and type is 'decimal'", () => typedCoercion("decimal", { - multiValued: true, - valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["integer value '1'", "integer", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect all values to be decimal numbers when attribute is multi-valued and type is 'decimal'", () => ( + typedCoercion("decimal", { + multiValued: true, + valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["integer value '1'", "integer", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect value to be an integer number when attribute type is 'integer'", () => typedCoercion("integer", { - valid: [["integer value '1'", 1]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["decimal value '1.01'", "decimal", 1.01], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect value to be an integer number when attribute type is 'integer'", () => ( + typedCoercion("integer", { + valid: [["integer value '1'", 1]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["decimal value '1.01'", "decimal", 1.01], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect all values to be integer numbers when attribute is multi-valued and type is 'integer'", () => typedCoercion("integer", { - multiValued: true, - valid: [["integer value '1'", 1]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["decimal value '1.01'", "decimal", 1.01], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect all values to be integer numbers when attribute is multi-valued and type is 'integer'", () => ( + typedCoercion("integer", { + multiValued: true, + valid: [["integer value '1'", 1]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["decimal value '1.01'", "decimal", 1.01], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect value to be a valid date instance or date string when attribute type is 'dateTime'", () => typedCoercion("dateTime", { - valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}] - ] - })); + it("should expect value to be a valid date instance or date string when attribute type is 'dateTime'", () => ( + typedCoercion("dateTime", { + valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}] + ] + }) + )); - it("should expect all values to be valid date instances or date strings when attribute is multi-valued and type is 'dateTime'", () => typedCoercion("dateTime", { - multiValued: true, - valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}] - ] - })); + it("should expect all values to be valid date instances or date strings when attribute is multi-valued and type is 'dateTime'", () => ( + typedCoercion("dateTime", { + multiValued: true, + valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}] + ] + }) + )); it("should expect value to be a valid reference when attribute type is 'reference'", () => { assert.throws(() => new SCIMMY.Types.Attribute("reference", "test").coerce("a string"), @@ -394,28 +415,32 @@ export let AttributeSuite = (SCIMMY) => { }); }); - it("should expect value to be an object when attribute type is 'complex'", () => typedCoercion("complex", { - assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, - valid: [["complex value", {}]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect value to be an object when attribute type is 'complex'", () => ( + typedCoercion("complex", { + assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, + valid: [["complex value", {}]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); - it("should expect all values to be objects when attribute is multi-valued and type is 'complex'", () => typedCoercion("complex", { - multiValued: true, - assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, - valid: [["complex value", {}]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - })); + it("should expect all values to be objects when attribute is multi-valued and type is 'complex'", () => ( + typedCoercion("complex", { + multiValued: true, + assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, + valid: [["complex value", {}]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 074c71b..ec83ae7 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -2,13 +2,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; import {instantiateFromFixture} from "./attribute.js"; -export let SchemaDefinitionSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./definition.json"), "utf8").then((f) => JSON.parse(f)); - const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test"}; - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./definition.json"), "utf8").then((f) => JSON.parse(f)); +const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test"}; + +export const SchemaDefinitionSuite = () => { it("should include static class 'SchemaDefinition'", () => assert.ok(!!SCIMMY.Types.SchemaDefinition, "Static class 'SchemaDefinition' not defined")); @@ -94,7 +95,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { for (let fixture of suite) { const definition = new SCIMMY.Types.SchemaDefinition( fixture.source.name, fixture.source.id, fixture.source.description, - fixture.source.attributes.map((a) => instantiateFromFixture(SCIMMY, a)) + fixture.source.attributes.map((a) => instantiateFromFixture(a)) ); assert.deepStrictEqual(JSON.parse(JSON.stringify(definition.describe())), fixture.target, @@ -383,9 +384,8 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect the supplied filter to be applied to coerced result", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ - new SCIMMY.Types.Attribute("string", "testName"), new SCIMMY.Types.Attribute("string", "testValue") - ]); + const attributes = [new SCIMMY.Types.Attribute("string", "testName"), new SCIMMY.Types.Attribute("string", "testValue")]; + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", attributes); const result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new SCIMMY.Types.Filter("testName pr")); assert.ok(Object.keys(result).includes("testName"), @@ -395,7 +395,7 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema",[new SCIMMY.Types.Attribute("string", "employeeNumber")]) + const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [new SCIMMY.Types.Attribute("string", "employeeNumber")]) .extend(SCIMMY.Schemas.EnterpriseUser.definition); const result = definition.coerce( { @@ -414,4 +414,4 @@ export let SchemaDefinitionSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/error.js b/test/lib/types/error.js index 1906588..6675f87 100644 --- a/test/lib/types/error.js +++ b/test/lib/types/error.js @@ -1,6 +1,7 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let ErrorSuite = (SCIMMY) => { +export const ErrorSuite = () => { it("should include static class 'Error'", () => assert.ok(!!SCIMMY.Types.Error, "Static class 'Error' not defined")); @@ -35,4 +36,4 @@ export let ErrorSuite = (SCIMMY) => { "Error type class did not include instance member 'message'"); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 4f5ff45..5021283 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -2,11 +2,12 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let FilterSuite = (SCIMMY) => { - const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); - const fixtures = fs.readFile(path.join(basepath, "./filter.json"), "utf8").then((f) => JSON.parse(f)); - +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./filter.json"), "utf8").then((f) => JSON.parse(f)); + +export const FilterSuite = () => { it("should include static class 'Filter'", () => assert.ok(!!SCIMMY.Types.Filter, "Static class 'Filter' not defined")); @@ -23,7 +24,7 @@ export let FilterSuite = (SCIMMY) => { describe("#constructor", () => { it("should expect 'expression' argument to be a non-empty string or collection of objects", () => { - let fixtures = [ + const fixtures = [ ["number value '1'", 1], ["boolean value 'false'", false] ]; @@ -65,7 +66,7 @@ export let FilterSuite = (SCIMMY) => { }); it("should parse simple expressions without logical or grouping operators", async () => { - let {parse: {simple: suite}} = await fixtures; + const {parse: {simple: suite}} = await fixtures; for (let fixture of suite) { assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, @@ -74,7 +75,7 @@ export let FilterSuite = (SCIMMY) => { }); it("should parse expressions with logical operators", async () => { - let {parse: {logical: suite}} = await fixtures; + const {parse: {logical: suite}} = await fixtures; for (let fixture of suite) { assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, @@ -83,7 +84,7 @@ export let FilterSuite = (SCIMMY) => { }); it("should parse expressions with grouping operators", async () => { - let {parse: {grouping: suite}} = await fixtures; + const {parse: {grouping: suite}} = await fixtures; for (let fixture of suite) { assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, @@ -92,7 +93,7 @@ export let FilterSuite = (SCIMMY) => { }); it("should parse complex expressions with a mix of logical and grouping operators", async () => { - let {parse: {complex: suite}} = await fixtures; + const {parse: {complex: suite}} = await fixtures; for (let fixture of suite) { assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, @@ -120,7 +121,7 @@ export let FilterSuite = (SCIMMY) => { for (let [key, label] of targets) { it(`should ${label}`, async () => { - let {match: {source, targets: {[key]: suite}}} = await fixtures; + const {match: {source, targets: {[key]: suite}}} = await fixtures; for (let fixture of suite) { assert.deepStrictEqual(new SCIMMY.Types.Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, @@ -130,4 +131,4 @@ export let FilterSuite = (SCIMMY) => { } }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 0102d46..e80d004 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,6 +1,7 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let ResourceSuite = (SCIMMY) => { +export const ResourceSuite = () => { it("should include static class 'Resource'", () => assert.ok(!!SCIMMY.Types.Resource, "Static class 'Resource' not defined")); @@ -107,4 +108,4 @@ export let ResourceSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index 955dad1..0db39ba 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,6 +1,7 @@ import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; -export let SchemaSuite = (SCIMMY) => { +export const SchemaSuite = () => { it("should include static class 'Schema'", () => assert.ok(!!SCIMMY.Types.Schema, "Static class 'Schema' not defined")); @@ -27,4 +28,4 @@ export let SchemaSuite = (SCIMMY) => { }); }); }); -} \ No newline at end of file +}; \ No newline at end of file diff --git a/test/scimmy.js b/test/scimmy.js index 68ec2dc..66e986d 100644 --- a/test/scimmy.js +++ b/test/scimmy.js @@ -1,4 +1,3 @@ -import SCIMMY from "../src/scimmy.js"; import {ConfigSuite} from "./lib/config.js"; import {TypesSuite} from "./lib/types.js"; import {MessagesSuite} from "./lib/messages.js"; @@ -6,9 +5,9 @@ import {SchemasSuite} from "./lib/schemas.js"; import {ResourcesSuite} from "./lib/resources.js"; describe("SCIMMY", () => { - ConfigSuite(SCIMMY); - TypesSuite(SCIMMY); - MessagesSuite(SCIMMY); - SchemasSuite(SCIMMY); - ResourcesSuite(SCIMMY); + ConfigSuite(); + TypesSuite(); + MessagesSuite(); + SchemasSuite(); + ResourcesSuite(); }); \ No newline at end of file From 6fb88d01e81192e1109b42692ae7cd75a585a7e3 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 7 Apr 2023 15:00:55 +1000 Subject: [PATCH 53/93] Tests(SCIMMY.Schemas->.declared()): return 'false' when called with unexpected args --- test/lib/schemas.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/lib/schemas.js b/test/lib/schemas.js index 65371e6..aa5af1f 100644 --- a/test/lib/schemas.js +++ b/test/lib/schemas.js @@ -175,6 +175,11 @@ export const SchemasSuite = () => { "Static method 'declared' did not return all declared definitions when called without arguments"); }); + it("should return boolean 'false' when called with unexpected arguments", () => { + assert.strictEqual(SCIMMY.Schemas.declared({}), false, + "Static method 'declared' did not return boolean 'false' when called with unexpected arguments"); + }); + it("should find declaration status of definitions by name", () => { assert.ok(SCIMMY.Schemas.declared("User"), "Static method 'declared' did not find declaration status of declared 'User' schema by name"); From 3e58b445b373a54d7a3905feee749c6ed0e68f42 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 10 Apr 2023 14:24:30 +1000 Subject: [PATCH 54/93] Tests(SCIMMY.Types.Resource->.describe()): check properties of returned description --- test/lib/types/resource.js | 53 ++++++++++++++++++++++++++++++++++++++ test/lib/types/schema.js | 12 +++++++++ 2 files changed, 65 insertions(+) diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index e80d004..2f22b03 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,5 +1,27 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +import {createSchemaClass} from "./schema.js"; + +// Default values to use when creating a resource class in tests +const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test", description: "A Test"}; +const extension = ["Extension", "urn:ietf:params:scim:schemas:Extension", "An Extension"]; + +/** + * Create a class that extends SCIMMY.Types.Resource, for use in tests + * @param {String} name - the name of the Resource to create a class for + * @param {*[]} params - arguments to pass through to the Schema class + * @returns {typeof SCIMMY.Types.Resource} a class that extends SCIMMY.Types.Resource for use in tests + */ +export const createResourceClass = (name, ...params) => ( + class Test extends SCIMMY.Types.Resource { + static #endpoint = `/${name}` + static get endpoint() { return Test.#endpoint; } + static #extensions = []; + static get extensions() { return Test.#extensions; } + static #schema = createSchemaClass(name, ...params); + static get schema() { return Test.#schema; } + } +); export const ResourceSuite = () => { it("should include static class 'Resource'", () => @@ -106,6 +128,37 @@ export const ResourceSuite = () => { assert.ok(typeof SCIMMY.Types.Resource.describe === "function", "Static method 'describe' not defined"); }); + + const TestResource = createResourceClass(...Object.values(params)); + const properties = [ + ["name"], ["description"], ["id", "name"], ["schema", "id"], + ["endpoint", "name", `/${params.name}`, ", with leading forward-slash"] + ]; + + for (let [prop, target = prop, expected = params[target], suffix = ""] of properties) { + it(`should expect '${prop}' property of description to equal '${target}' property of resource's schema definition${suffix}`, () => { + assert.strictEqual(TestResource.describe()[prop], expected, + `Resource 'describe' method returned '${prop}' property with unexpected value`); + }); + } + + it("should expect 'schemaExtensions' property to be excluded in description when resource is not extended", () => { + assert.strictEqual(TestResource.describe().schemaExtensions, undefined, + "Resource 'describe' method unexpectedly included 'schemaExtensions' property in description"); + }); + + it("should expect 'schemaExtensions' property to be included in description when resource is extended", function () { + try { + TestResource.extend(createSchemaClass(...extension)); + } catch { + this.skip(); + } + + assert.ok(!!TestResource.describe().schemaExtensions, + "Resource 'describe' method did not include 'schemaExtensions' property in description"); + assert.deepStrictEqual(TestResource.describe().schemaExtensions, [{schema: "urn:ietf:params:scim:schemas:Extension", required: false}], + "Resource 'describe' method included 'schemaExtensions' property with unexpected value in description"); + }); }); }); }; \ No newline at end of file diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index 0db39ba..b6da5be 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,6 +1,18 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +/** + * Create a class that extends SCIMMY.Types.Schema, for use in tests + * @param {*[]} params - arguments to pass through to the SchemaDefinition instance + * @returns {typeof SCIMMY.Types.Schema} a class that extends SCIMMY.Types.Schema for use in tests + */ +export const createSchemaClass = (...params) => ( + class Test extends SCIMMY.Types.Schema { + static #definition = new SCIMMY.Types.SchemaDefinition(...params); + static get definition() { return Test.#definition; } + } +); + export const SchemaSuite = () => { it("should include static class 'Schema'", () => assert.ok(!!SCIMMY.Types.Schema, "Static class 'Schema' not defined")); From ff14283c8bc03e185e9bf4310253ebb22445fa94 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 10 Apr 2023 14:32:05 +1000 Subject: [PATCH 55/93] Fix(SCIMMY.Types.Resource->.describe()): find schemaExtensions dynamically from resource type's SchemaDefinition --- src/lib/types/resource.js | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 497cfe8..4d6bb42 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -1,4 +1,5 @@ import {SCIMError} from "./error.js"; +import {SchemaDefinition} from "./definition.js"; import {Schema} from "./schema.js"; import {Filter} from "./filter.js"; @@ -38,7 +39,7 @@ export class Resource { /** * Retrieves a resource's core schema - * @type {SCIMMY.Types.Schema} + * @type {typeof SCIMMY.Types.Schema} * @abstract */ static get schema() { @@ -139,22 +140,27 @@ export class Resource { * @returns {SCIMMY.Types.Resource~ResourceType} object describing the resource type implementation */ static describe() { + // Find all schema definitions that extend this resource's definition... + const findSchemaDefinitions = (d) => d.attributes.filter(a => a instanceof SchemaDefinition) + .map(e => ([e, ...findSchemaDefinitions(e)])).flat(Infinity); + // ...so they can be included in the returned description + const schemaExtensions = [...new Set(findSchemaDefinitions(this.schema.definition))] + .map(({id: schema, required}) => ({schema, required})); + /** * @typedef {Object} SCIMMY.Types.Resource~ResourceType * @property {String} id - URN namespace of the resource's SCIM schema definition * @property {String} name - friendly name of the resource's SCIM schema definition * @property {String} endpoint - resource type's endpoint, relative to a service provider's base URL * @property {String} description - human-readable description of the resource - * @property {Object} [schemaExtensions] - schema extensions that augment the resource + * @property {Object[]} [schemaExtensions] - schema extensions that augment the resource * @property {String} schemaExtensions[].schema - URN namespace of the schema extension that augments the resource * @property {Boolean} schemaExtensions[].required - whether resource instances must include the schema extension */ return { id: this.schema.definition.name, name: this.schema.definition.name, endpoint: this.endpoint, description: this.schema.definition.description, schema: this.schema.definition.id, - ...(this.extensions.length === 0 ? {} : { - schemaExtensions: this.extensions.map(E => ({schema: E.schema.definition.id, required: E.required})) - }) + ...(schemaExtensions.length ? {schemaExtensions} : {}) }; } @@ -231,10 +237,10 @@ export class Resource { count = Number(sCount ?? undefined); this.constraints = { - ...(sortBy !== undefined ? {sortBy: sortBy} : {}), - ...(["ascending", "descending"].includes(sortOrder) ? {sortOrder: sortOrder} : {}), - ...(!Number.isNaN(startIndex) && Number.isInteger(startIndex) ? {startIndex: startIndex} : {}), - ...(!Number.isNaN(count) && Number.isInteger(count) ? {count: count} : {}) + ...(sortBy !== undefined ? {sortBy} : {}), + ...(["ascending", "descending"].includes(sortOrder) ? {sortOrder} : {}), + ...(!Number.isNaN(startIndex) && Number.isInteger(startIndex) ? {startIndex} : {}), + ...(!Number.isNaN(count) && Number.isInteger(count) ? {count} : {}) }; } } From a2305330707eedb39b03487695ebc75648cc321b Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 10 Apr 2023 15:02:36 +1000 Subject: [PATCH 56/93] Refactor(SCIMMY.Types.Resource): remove redundant 'extensions' property --- src/lib/resources.js | 8 ++++---- src/lib/resources/group.js | 7 ------- src/lib/resources/user.js | 7 ------- src/lib/schemas.js | 5 ----- src/lib/types/resource.js | 27 ++++----------------------- test/lib/resources.js | 11 ----------- test/lib/resources/group.js | 1 - test/lib/resources/resourcetype.js | 1 - test/lib/resources/schema.js | 1 - test/lib/resources/spconfig.js | 1 - test/lib/resources/user.js | 1 - test/lib/types/resource.js | 10 ---------- 12 files changed, 8 insertions(+), 72 deletions(-) diff --git a/src/lib/resources.js b/src/lib/resources.js index 61e773e..0ad759f 100644 --- a/src/lib/resources.js +++ b/src/lib/resources.js @@ -129,9 +129,9 @@ export default class Resources { /** * Register a resource implementation for exposure as a ResourceType - * @param {SCIMMY.Types.Resource} resource - the resource type implementation to register + * @param {typeof SCIMMY.Types.Resource} resource - the resource type implementation to register * @param {Object|String} [config] - the configuration to feed to the resource being registered, or the name of the resource type implementation if different to the class name - * @returns {SCIMMY.Resources|SCIMMY.Types.Resource} the Resources class or registered resource type class for chaining + * @returns {typeof SCIMMY.Resources|typeof SCIMMY.Types.Resource} the Resources class or registered resource type class for chaining */ static declare(resource, config) { // Make sure the registering resource is valid @@ -189,8 +189,8 @@ export default class Resources { /** * Get registration status of specific resource implementation, or get all registered resource implementations - * @param {SCIMMY.Types.Resource|String} [resource] - the resource implementation or name to query registration status for - * @returns {Object|SCIMMY.Types.Resource|Boolean} + * @param {typeof SCIMMY.Types.Resource|String} [resource] - the resource implementation or name to query registration status for + * @returns {Object|typeof SCIMMY.Types.Resource|Boolean} * * A containing object with registered resource implementations for exposure as ResourceTypes, if no arguments are supplied. * * The registered resource type implementation with matching name, or undefined, if a string argument is supplied. * * The registration status of the specified resource implementation, if a class extending `SCIMMY.Types.Resource` is supplied. diff --git a/src/lib/resources/group.js b/src/lib/resources/group.js index b66ad2a..88e26a3 100644 --- a/src/lib/resources/group.js +++ b/src/lib/resources/group.js @@ -31,13 +31,6 @@ export class Group extends Types.Resource { return Schemas.Group; } - /** @private */ - static #extensions = []; - /** @implements {SCIMMY.Types.Resource.extensions} */ - static get extensions() { - return Group.#extensions; - } - /** @private */ static #ingress = () => {}; /** @implements {SCIMMY.Types.Resource.ingress} */ diff --git a/src/lib/resources/user.js b/src/lib/resources/user.js index 932460c..6b721c6 100644 --- a/src/lib/resources/user.js +++ b/src/lib/resources/user.js @@ -31,13 +31,6 @@ export class User extends Types.Resource { return Schemas.User; } - /** @private */ - static #extensions = []; - /** @implements {SCIMMY.Types.Resource.extensions} */ - static get extensions() { - return User.#extensions; - } - /** @private */ static #ingress = () => {}; /** @implements {SCIMMY.Types.Resource.ingress} */ diff --git a/src/lib/schemas.js b/src/lib/schemas.js index 0a286eb..e68fa41 100644 --- a/src/lib/schemas.js +++ b/src/lib/schemas.js @@ -66,11 +66,6 @@ import {ServiceProviderConfig} from "./schemas/spconfig.js"; * // Add custom "mail" attribute to the Group schema definition * SCIMMY.Schemas.Group.definition.extend([new SCIMMY.Types.Attribute("string", "mail", {required: true})]); * ``` - * - * > **Note:** - * > Extension schemas should be added via a resource type implementation's `extend` method (see `{@link SCIMMY.Resources}` for more details). - * > Extensions added via a schema definition's `extend` method will **not** be included in the `schemaExtensions` - * > property by the `{@link SCIMMY.Resources.ResourceType}` resource type. */ export default class Schemas { // Store declared schema definitions for later retrieval diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 4d6bb42..4a7138c 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -46,33 +46,14 @@ export class Resource { throw new TypeError(`Method 'get' for property 'schema' not implemented by resource '${this.name}'`); } - /** - * List of extensions to a resource's core schema - * @type {Object[]} - * @private - * @abstract - */ - static #extensions; - /** - * Get the list of registered schema extensions for a resource - * @type {Object[]} - * @abstract - */ - static get extensions() { - throw new TypeError(`Method 'get' for property 'extensions' not implemented by resource '${this.name}'`); - } - /** * Register an extension to the resource's core schema - * @param {SCIMMY.Types.Schema} extension - the schema extension to register - * @param {Boolean} required - whether the extension is required + * @param {typeof SCIMMY.Types.Schema} extension - the schema extension to register + * @param {Boolean} [required] - whether the extension is required * @returns {SCIMMY.Types.Resource|void} this resource type implementation for chaining */ static extend(extension, required) { - if (!this.extensions.find(e => e.schema === extension)) { - if (extension.prototype instanceof Schema) this.extensions.push({schema: extension, required: required}); - this.schema.extend(extension, required); - } + this.schema.extend(extension, required); return this; } @@ -153,7 +134,7 @@ export class Resource { * @property {String} name - friendly name of the resource's SCIM schema definition * @property {String} endpoint - resource type's endpoint, relative to a service provider's base URL * @property {String} description - human-readable description of the resource - * @property {Object[]} [schemaExtensions] - schema extensions that augment the resource + * @property {Object} [schemaExtensions] - schema extensions that augment the resource * @property {String} schemaExtensions[].schema - URN namespace of the schema extension that augments the resource * @property {Boolean} schemaExtensions[].required - whether resource instances must include the schema extension */ diff --git a/test/lib/resources.js b/test/lib/resources.js index e984a4d..35827e7 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -24,17 +24,6 @@ export const ResourcesHooks = { "Static member 'schema' unexpectedly implemented by resource"); } }), - extensions: (TargetResource, implemented = true) => (() => { - if (implemented) { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extensions"), - "Resource did not implement static member 'extensions'"); - assert.ok(Array.isArray(TargetResource.extensions), - "Static member 'extensions' was not an array"); - } else { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extensions"), - "Static member 'extensions' unexpectedly implemented by resource"); - } - }), extend: (TargetResource, overrides = false) => (() => { if (!overrides) { assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), diff --git a/test/lib/resources/group.js b/test/lib/resources/group.js index ee237f8..db39ec6 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -15,7 +15,6 @@ export const GroupSuite = () => { describe("SCIMMY.Resources.Group", () => { it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.Group)); it("should implement static member 'schema' that is a Schema", ResourcesHooks.schema(SCIMMY.Resources.Group)); - it("should implement static member 'extensions' that is an array", ResourcesHooks.extensions(SCIMMY.Resources.Group)); it("should not override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.Group, false)); it("should implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.Group, fixtures)); it("should implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.Group, fixtures)); diff --git a/test/lib/resources/resourcetype.js b/test/lib/resources/resourcetype.js index 0d81805..94dfc30 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -15,7 +15,6 @@ export const ResourceTypeSuite = () => { describe("SCIMMY.Resources.ResourceType", () => { it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.ResourceType)); it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.ResourceType, false)); - it("should not implement static member 'extensions'", ResourcesHooks.extensions(SCIMMY.Resources.ResourceType, false)); it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.ResourceType, true)); it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.ResourceType, false)); it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.ResourceType, false)); diff --git a/test/lib/resources/schema.js b/test/lib/resources/schema.js index 987805b..f4b80ca 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -15,7 +15,6 @@ export const SchemaSuite = () => { describe("SCIMMY.Resources.Schema", () => { it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.Schema)); it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.Schema, false)); - it("should not implement static member 'extensions'", ResourcesHooks.extensions(SCIMMY.Resources.Schema, false)); it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.Schema, true)); it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.Schema, false)); it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.Schema, false)); diff --git a/test/lib/resources/spconfig.js b/test/lib/resources/spconfig.js index 8d244a0..c8a8d1a 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -15,7 +15,6 @@ export const ServiceProviderConfigSuite = () => { describe("SCIMMY.Resources.ServiceProviderConfig", () => { it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.ServiceProviderConfig)); it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement static member 'extensions'", ResourcesHooks.extensions(SCIMMY.Resources.ServiceProviderConfig, false)); it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.ServiceProviderConfig, true)); it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.ServiceProviderConfig, false)); it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.ServiceProviderConfig, false)); diff --git a/test/lib/resources/user.js b/test/lib/resources/user.js index d660741..da80823 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -15,7 +15,6 @@ export const UserSuite = () => { describe("SCIMMY.Resources.User", () => { it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.User)); it("should implement static member 'schema' that is a Schema", ResourcesHooks.schema(SCIMMY.Resources.User)); - it("should implement static member 'extensions' that is an array", ResourcesHooks.extensions(SCIMMY.Resources.User)); it("should not override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.User, false)); it("should implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.User, fixtures)); it("should implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.User, fixtures)); diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 2f22b03..2ffad95 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -16,8 +16,6 @@ export const createResourceClass = (name, ...params) => ( class Test extends SCIMMY.Types.Resource { static #endpoint = `/${name}` static get endpoint() { return Test.#endpoint; } - static #extensions = []; - static get extensions() { return Test.#extensions; } static #schema = createSchemaClass(name, ...params); static get schema() { return Test.#schema; } } @@ -44,14 +42,6 @@ export const ResourceSuite = () => { "Static member 'schema' not abstract"); }); - it("should have abstract static member 'extensions'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(SCIMMY.Types.Resource, "extensions").get === "function", - "Abstract static member 'extensions' not defined"); - assert.throws(() => SCIMMY.Types.Resource.extensions, - {name: "TypeError", message: "Method 'get' for property 'extensions' not implemented by resource 'Resource'"}, - "Static member 'extensions' not abstract"); - }); - it("should have abstract static method 'basepath'", () => { assert.ok(typeof SCIMMY.Types.Resource.basepath === "function", "Abstract static method 'basepath' not defined"); From c27ca06584e3719d726abac9d7b62be240604413 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 11 Apr 2023 15:57:07 +1000 Subject: [PATCH 57/93] Fix(SCIMMY.Config): set documentationUri to undefined instead of deleting property --- src/lib/config.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/config.js b/src/lib/config.js index 7a37572..28f59dd 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -122,8 +122,9 @@ export default class Config { */ static get() { // Wrap all the things in a proxy! - return new Proxy(Object.entries(Config.#config) - .reduce((res, [key, value]) => (((res[key] = (key === "documentationUri" ? value : new Proxy(value, handleTraps))) || true) && res), {}), handleTraps); + return new Proxy(Object.entries(Config.#config).reduce((res, [key, value]) => Object.assign(res, { + [key]: (key === "documentationUri" ? value : new Proxy(value, handleTraps)) + }), {}), handleTraps); } /** @@ -132,7 +133,7 @@ export default class Config { * @param {Object} args - the new configuration to apply to the service provider config instance * @param {String} args - the name of the configuration property to set * @param {Object|Boolean} args - the new value of the configuration property to set - * @returns {Object|SCIMMY.Config} the updated configuration instance, or the config container class for chaining + * @returns {Object|typeof SCIMMY.Config} the updated configuration instance, or the config container class for chaining */ static set(...args) { // Dereference name and config from supplied parameters @@ -163,7 +164,7 @@ export default class Config { // Assign documentationUri string if (!!value) Config.#config.documentationUri = Schemas.ServiceProviderConfig.definition.attribute(key).coerce(value); - else delete Config.#config.documentationUri; + else Config.#config.documentationUri = undefined; } else if (Array.isArray(target)) { // Target is multi-valued (authenticationSchemes), add coerced values to config, or reset if empty if (!value || (Array.isArray(value) && value.length === 0)) target.splice(0); From 2675ab87c639c4af31705d78339fb106a72e08a0 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 11 Apr 2023 15:58:38 +1000 Subject: [PATCH 58/93] Tests(SCIMMY.Config->.set()): accepted 'documentationUri' behaviour --- test/lib/config.js | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/test/lib/config.js b/test/lib/config.js index 87b0712..e25f34a 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -14,27 +14,33 @@ export const ConfigSuite = () => { assert.ok(!!SCIMMY.Config, "Static class 'Config' not defined")); describe("SCIMMY.Config", () => { + // Reset the configuration after all config tests are complete + after(() => SCIMMY.Config.set({ + documentationUri: false, authenticationSchemes: [], changePassword: false, + etag: false, patch: false, sort: false, filter: {maxResults: 200, supported: false}, + bulk: {maxOperations: 1000, maxPayloadSize: 1048576, supported: false} + })); + describe(".get()", () => { it("should have static method 'get'", () => { assert.ok(typeof SCIMMY.Config.get === "function", "Static method 'get' not defined"); }); - it("should return an immutable object", () => - returnsImmutableObject("get", SCIMMY.Config.get())); + it("should return an immutable object", () => ( + returnsImmutableObject("get", SCIMMY.Config.get()) + )); }); describe(".set()", () => { - const origin = JSON.parse(JSON.stringify(SCIMMY.Config.get())); - after(() => SCIMMY.Config.set(origin)); - it("should have static method 'set'", () => { assert.ok(typeof SCIMMY.Config.set === "function", "Static method 'set' not defined"); }); - it("should return an immutable object", () => - returnsImmutableObject("set", SCIMMY.Config.set())); + it("should return an immutable object", () => ( + returnsImmutableObject("set", SCIMMY.Config.set()) + )); it("should do nothing without arguments", () => { const config = SCIMMY.Config.get(); @@ -61,6 +67,22 @@ export const ConfigSuite = () => { "Static method 'set' accepted boolean value 'true' for 'documentationUri' attribute"); }); + it("should not accept string value 'true' for 'documentationUri' attribute", () => { + assert.throws(() => SCIMMY.Config.set("documentationUri", "true"), + {name: "TypeError", message: "Attribute 'documentationUri' expected value type 'reference' to refer to one of: 'external'"}, + "Static method 'set' accepted string value 'true' for 'documentationUri' attribute"); + }); + + it("should accept string value 'https://example.com' for 'documentationUri' attribute", () => { + assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", "https://example.com"), + "Static method 'set' did not accept string value 'https://example.com' for 'documentationUri' attribute"); + }); + + it("should accept boolean value 'false' for 'documentationUri' attribute", () => { + assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", false), + "Static method 'set' did not accept boolean value 'false' for 'documentationUri' attribute"); + }); + it("should not accept boolean value 'true' for 'authenticationSchemes' attribute", () => { assert.throws(() => SCIMMY.Config.set("authenticationSchemes", true), {name: "TypeError", message: "Complex attribute 'authenticationSchemes' expected complex value but found type 'boolean'"}, From c6c043ccd6f6ad26a67f444353ff115bc204bac0 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 13 Apr 2023 17:52:50 +1000 Subject: [PATCH 59/93] Tests: add mocking and isolation, enable parallel execution --- package.json | 1 + packager.js | 25 +- test/lib/config.js | 217 ++++---- test/lib/messages.js | 48 +- test/lib/messages/bulkrequest.js | 496 ++++++++++--------- test/lib/messages/bulkresponse.js | 67 ++- test/lib/messages/error.js | 93 ++-- test/lib/messages/listresponse.js | 263 +++++----- test/lib/messages/patchop.js | 362 +++++++------- test/lib/messages/searchrequest.js | 422 ++++++++-------- test/lib/resources.js | 527 +++++++++++--------- test/lib/resources/group.js | 37 +- test/lib/resources/resourcetype.js | 45 +- test/lib/resources/schema.js | 45 +- test/lib/resources/spconfig.js | 46 +- test/lib/resources/user.js | 37 +- test/lib/schemas.js | 222 +++++---- test/lib/schemas/enterpriseuser.js | 16 +- test/lib/schemas/group.js | 16 +- test/lib/schemas/resourcetype.js | 16 +- test/lib/schemas/spconfig.js | 16 +- test/lib/schemas/user.js | 16 +- test/lib/types.js | 46 +- test/lib/types/attribute.js | 763 ++++++++++++++--------------- test/lib/types/definition.js | 716 ++++++++++++++------------- test/lib/types/error.js | 65 ++- test/lib/types/filter.js | 215 ++++---- test/lib/types/resource.js | 239 +++++---- test/lib/types/schema.js | 52 +- test/scimmy.js | 36 +- 30 files changed, 2606 insertions(+), 2559 deletions(-) diff --git a/package.json b/package.json index cd51d78..8e5cda7 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "minimist": "^1.2.8", "mocha": "^10.2.0", "rollup": "^3.20.2", + "sinon": "^15.0.3", "typescript": "^5.0.2" } } diff --git a/packager.js b/packager.js index b0c0f33..92a80b1 100644 --- a/packager.js +++ b/packager.js @@ -15,11 +15,12 @@ const basepath = path.relative(process.cwd(), cwd); export class Packager { /** * Various paths to locations of assets used in packaging process - * @type {{src: string, dist: string}} + * @type {{src: string, dist: string, test: string}} */ static paths = { src: `./${path.join(basepath, "src")}`, - dist: `./${path.join(basepath, "dist")}` + dist: `./${path.join(basepath, "dist")}`, + test: `./${path.join(basepath, "test")}` }; /** @@ -152,16 +153,24 @@ export class Packager { */ static async test(filter, reporter = "base") { const {default: Mocha} = await import("mocha"); - let mocha = new Mocha() - .reporter(...(typeof reporter === "object" ? [reporter.name, reporter.options] : [reporter])) - .addFile(`./${path.join(basepath, "./test/scimmy.js")}`) - .grep(`/${filter ?? ".*"}/i`); + const mocha = new Mocha().reporter(...(typeof reporter === "object" ? [reporter.name, reporter.options] : [reporter])); + // Recursively go through directories and find all test files + const find = async (dir) => (await Promise.all((await fs.readdir(dir, {withFileTypes: true})) + // Put files above directories, then go through and find all files recursively + .sort((fa, fb) => -fa.isFile()+fb.isFile()) + .map(async (file) => ([file.isFile() && path.join(dir, file.name), ...(file.isDirectory() ? await find(path.join(dir, file.name)) : [])])))) + // Collapse the pyramid and find all actual test files + .flat(Infinity).filter(filename => !!filename && filename.endsWith(".js")); + + // Let mocha know about the test files + for (let file of await find(Packager.paths.test)) mocha.addFile(file); return new Promise((resolve, reject) => { + mocha.grep(`/^${(filter ?? "").split("").map(s => `[${s}]`).join("") ?? ".*"}/i`); mocha.timeout("2m").loadFilesAsync().then(() => mocha.run()).then((runner) => { if (reporter === "base") { - let reporter = new Mocha.reporters.Base(runner), - epilogue = reporter.epilogue.bind(reporter); + const reporter = new Mocha.reporters.Base(runner); + const epilogue = reporter.epilogue.bind(reporter); runner.on("end", () => !!reporter.stats.failures ? reject(epilogue) : resolve(epilogue)); } diff --git a/test/lib/config.js b/test/lib/config.js index e25f34a..bbbf2dc 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -9,129 +9,124 @@ function returnsImmutableObject(name, config) { `Static method '${name}' returned a mutable object`); } -export const ConfigSuite = () => { - it("should include static class 'Config'", () => - assert.ok(!!SCIMMY.Config, "Static class 'Config' not defined")); +describe("SCIMMY.Config", () => { + // Reset the configuration after all config tests are complete + after(() => SCIMMY.Config.set({ + documentationUri: false, authenticationSchemes: [], changePassword: false, + etag: false, patch: false, sort: false, filter: {maxResults: 200, supported: false}, + bulk: {maxOperations: 1000, maxPayloadSize: 1048576, supported: false} + })); - describe("SCIMMY.Config", () => { - // Reset the configuration after all config tests are complete - after(() => SCIMMY.Config.set({ - documentationUri: false, authenticationSchemes: [], changePassword: false, - etag: false, patch: false, sort: false, filter: {maxResults: 200, supported: false}, - bulk: {maxOperations: 1000, maxPayloadSize: 1048576, supported: false} - })); + describe(".get()", () => { + it("should have static method 'get'", () => { + assert.ok(typeof SCIMMY.Config.get === "function", + "Static method 'get' not defined"); + }); - describe(".get()", () => { - it("should have static method 'get'", () => { - assert.ok(typeof SCIMMY.Config.get === "function", - "Static method 'get' not defined"); - }); - - it("should return an immutable object", () => ( - returnsImmutableObject("get", SCIMMY.Config.get()) - )); + it("should return an immutable object", () => ( + returnsImmutableObject("get", SCIMMY.Config.get()) + )); + }); + + describe(".set()", () => { + it("should have static method 'set'", () => { + assert.ok(typeof SCIMMY.Config.set === "function", + "Static method 'set' not defined"); }); - describe(".set()", () => { - it("should have static method 'set'", () => { - assert.ok(typeof SCIMMY.Config.set === "function", - "Static method 'set' not defined"); - }); - - it("should return an immutable object", () => ( - returnsImmutableObject("set", SCIMMY.Config.set()) - )); - - it("should do nothing without arguments", () => { - const config = SCIMMY.Config.get(); - - assert.deepStrictEqual(SCIMMY.Config.set(), config, - "Static method 'set' unexpectedly modified config"); - }); + it("should return an immutable object", () => ( + returnsImmutableObject("set", SCIMMY.Config.set()) + )); + + it("should do nothing without arguments", () => { + const config = SCIMMY.Config.get(); - it("should not accept unknown attributes", () => { - assert.throws(() => SCIMMY.Config.set("test", true), - {name: "TypeError", message: "SCIM configuration: schema does not define attribute 'test'"}, - "Static method 'set' accepted unknown attribute name string"); - assert.throws(() => SCIMMY.Config.set({test: true}), - {name: "TypeError", message: "SCIM configuration: schema does not define attribute 'test'"}, - "Static method 'set' accepted unknown attribute object key"); - assert.throws(() => SCIMMY.Config.set("patch", {test: true}), - {name: "TypeError", message: "SCIM configuration: complex attribute 'patch' does not declare subAttribute 'test'"}, - "Static method 'set' accepted unknown sub-attribute object key"); + assert.deepStrictEqual(SCIMMY.Config.set(), config, + "Static method 'set' unexpectedly modified config"); + }); + + it("should not accept unknown attributes", () => { + assert.throws(() => SCIMMY.Config.set("test", true), + {name: "TypeError", message: "SCIM configuration: schema does not define attribute 'test'"}, + "Static method 'set' accepted unknown attribute name string"); + assert.throws(() => SCIMMY.Config.set({test: true}), + {name: "TypeError", message: "SCIM configuration: schema does not define attribute 'test'"}, + "Static method 'set' accepted unknown attribute object key"); + assert.throws(() => SCIMMY.Config.set("patch", {test: true}), + {name: "TypeError", message: "SCIM configuration: complex attribute 'patch' does not declare subAttribute 'test'"}, + "Static method 'set' accepted unknown sub-attribute object key"); + }); + + it("should not accept boolean value 'true' for 'documentationUri' attribute", () => { + assert.throws(() => SCIMMY.Config.set("documentationUri", true), + {name: "TypeError", message: "SCIM configuration: attribute 'documentationUri' expected value type 'string'"}, + "Static method 'set' accepted boolean value 'true' for 'documentationUri' attribute"); + }); + + it("should not accept string value 'true' for 'documentationUri' attribute", () => { + assert.throws(() => SCIMMY.Config.set("documentationUri", "true"), + {name: "TypeError", message: "Attribute 'documentationUri' expected value type 'reference' to refer to one of: 'external'"}, + "Static method 'set' accepted string value 'true' for 'documentationUri' attribute"); + }); + + it("should accept string value 'https://example.com' for 'documentationUri' attribute", () => { + assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", "https://example.com"), + "Static method 'set' did not accept string value 'https://example.com' for 'documentationUri' attribute"); + }); + + it("should accept boolean value 'false' for 'documentationUri' attribute", () => { + assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", false), + "Static method 'set' did not accept boolean value 'false' for 'documentationUri' attribute"); + }); + + it("should not accept boolean value 'true' for 'authenticationSchemes' attribute", () => { + assert.throws(() => SCIMMY.Config.set("authenticationSchemes", true), + {name: "TypeError", message: "Complex attribute 'authenticationSchemes' expected complex value but found type 'boolean'"}, + "Static method 'set' accepted boolean value 'true' for 'authenticationSchemes' attribute"); + }); + + for (let attrib of ["patch", "bulk", "filter", "changePassword", "sort", "etag"]) { + it(`should accept shorthand boolean values 'true' and 'false' for '${attrib}' attribute`, () => { + for (let value of [true, false]) { + SCIMMY.Config.set(attrib, value); + assert.strictEqual(SCIMMY.Config.get()[attrib].supported, value, + `Static method 'set' did not accept boolean value '${value}' for '${attrib}' attribute`); + } }); - it("should not accept boolean value 'true' for 'documentationUri' attribute", () => { - assert.throws(() => SCIMMY.Config.set("documentationUri", true), - {name: "TypeError", message: "SCIM configuration: attribute 'documentationUri' expected value type 'string'"}, - "Static method 'set' accepted boolean value 'true' for 'documentationUri' attribute"); + it(`should accept complex value 'supported' for '${attrib}' attribute`, () => { + SCIMMY.Config.set(attrib, {supported: true}); + assert.strictEqual(SCIMMY.Config.get()[attrib].supported, true, + `Static method 'set' did not accept complex value 'supported' for '${attrib}' attribute`); }); - it("should not accept string value 'true' for 'documentationUri' attribute", () => { - assert.throws(() => SCIMMY.Config.set("documentationUri", "true"), - {name: "TypeError", message: "Attribute 'documentationUri' expected value type 'reference' to refer to one of: 'external'"}, - "Static method 'set' accepted string value 'true' for 'documentationUri' attribute"); + it(`should not accept shorthand string value for '${attrib}' attribute`, () => { + assert.throws(() => SCIMMY.Config.set(attrib, "test"), + {name: "TypeError", message: `SCIM configuration: attribute '${attrib}' expected value type 'complex' but got 'string'`}, + `Static method 'set' accepted shorthand string value for '${attrib}' attribute`); }); - - it("should accept string value 'https://example.com' for 'documentationUri' attribute", () => { - assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", "https://example.com"), - "Static method 'set' did not accept string value 'https://example.com' for 'documentationUri' attribute"); + } + + for (let attrib of ["bulk", "filter"]) { + it(`should accept shorthand positive integer value for '${attrib}' attribute`, () => { + SCIMMY.Config.set(attrib, 100); + assert.strictEqual(SCIMMY.Config.get()[attrib][attrib === "filter" ? "maxResults" : "maxOperations"], 100, + `Static method 'set' did not accept shorthand positive integer value for '${attrib}' attribute`); }); - it("should accept boolean value 'false' for 'documentationUri' attribute", () => { - assert.doesNotThrow(() => SCIMMY.Config.set("documentationUri", false), - "Static method 'set' did not accept boolean value 'false' for 'documentationUri' attribute"); + it(`should not accept shorthand negative integer value for '${attrib}' attribute`, () => { + assert.throws(() => SCIMMY.Config.set(attrib, -1), + {name: "TypeError", message: `SCIM configuration: property '${attrib}' expects number value to be zero or more`}, + `Static method 'set' accepted shorthand negative integer value for '${attrib}' attribute`); }); - - it("should not accept boolean value 'true' for 'authenticationSchemes' attribute", () => { - assert.throws(() => SCIMMY.Config.set("authenticationSchemes", true), - {name: "TypeError", message: "Complex attribute 'authenticationSchemes' expected complex value but found type 'boolean'"}, - "Static method 'set' accepted boolean value 'true' for 'authenticationSchemes' attribute"); + } + + for (let attrib of ["patch", "changePassword", "sort", "etag"]) { + it(`should not accept shorthand integer value for '${attrib}' attribute`, () => { + assert.throws(() => SCIMMY.Config.set(attrib, 1), + {name: "TypeError", message: `SCIM configuration: property '${attrib}' does not define any number-based attributes`}, + `Static method 'set' accepted shorthand integer value for '${attrib}' attribute`); }); - - for (let attrib of ["patch", "bulk", "filter", "changePassword", "sort", "etag"]) { - it(`should accept shorthand boolean values 'true' and 'false' for '${attrib}' attribute`, () => { - for (let value of [true, false]) { - SCIMMY.Config.set(attrib, value); - assert.strictEqual(SCIMMY.Config.get()[attrib].supported, value, - `Static method 'set' did not accept boolean value '${value}' for '${attrib}' attribute`); - } - }); - - it(`should accept complex value 'supported' for '${attrib}' attribute`, () => { - SCIMMY.Config.set(attrib, {supported: true}); - assert.strictEqual(SCIMMY.Config.get()[attrib].supported, true, - `Static method 'set' did not accept complex value 'supported' for '${attrib}' attribute`); - }); - - it(`should not accept shorthand string value for '${attrib}' attribute`, () => { - assert.throws(() => SCIMMY.Config.set(attrib, "test"), - {name: "TypeError", message: `SCIM configuration: attribute '${attrib}' expected value type 'complex' but got 'string'`}, - `Static method 'set' accepted shorthand string value for '${attrib}' attribute`); - }); - } - - for (let attrib of ["bulk", "filter"]) { - it(`should accept shorthand positive integer value for '${attrib}' attribute`, () => { - SCIMMY.Config.set(attrib, 100); - assert.strictEqual(SCIMMY.Config.get()[attrib][attrib === "filter" ? "maxResults" : "maxOperations"], 100, - `Static method 'set' did not accept shorthand positive integer value for '${attrib}' attribute`); - }); - - it(`should not accept shorthand negative integer value for '${attrib}' attribute`, () => { - assert.throws(() => SCIMMY.Config.set(attrib, -1), - {name: "TypeError", message: `SCIM configuration: property '${attrib}' expects number value to be zero or more`}, - `Static method 'set' accepted shorthand negative integer value for '${attrib}' attribute`); - }); - } - - for (let attrib of ["patch", "changePassword", "sort", "etag"]) { - it(`should not accept shorthand integer value for '${attrib}' attribute`, () => { - assert.throws(() => SCIMMY.Config.set(attrib, 1), - {name: "TypeError", message: `SCIM configuration: property '${attrib}' does not define any number-based attributes`}, - `Static method 'set' accepted shorthand integer value for '${attrib}' attribute`); - }); - } - }); + } }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages.js b/test/lib/messages.js index 06068dc..1b78069 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -1,22 +1,34 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; -import {ErrorSuite} from "./messages/error.js"; -import {ListResponseSuite} from "./messages/listresponse.js"; -import {PatchOpSuite} from "./messages/patchop.js"; -import {BulkRequestSuite} from "./messages/bulkrequest.js"; -import {BulkResponseSuite} from "./messages/bulkresponse.js"; -import {SearchRequestSuite} from "./messages/searchrequest.js"; +import Messages from "#@/lib/messages.js"; -export const MessagesSuite = () => { - it("should include static class 'Messages'", () => - assert.ok(!!SCIMMY.Messages, "Static class 'Messages' not defined")); +describe("SCIMMY.Messages", () => { + it("should include static class 'Error'", () => { + assert.ok(!!Messages.Error, + "Static class 'Error' not defined"); + }); + + it("should include static class 'ListResponse'", () => { + assert.ok(!!Messages.ListResponse, + "Static class 'ListResponse' not defined"); + }); + + it("should include static class 'PatchOp'", () => { + assert.ok(!!Messages.PatchOp, + "Static class 'PatchOp' not defined"); + }); + + it("should include static class 'BulkRequest'", () => { + assert.ok(!!Messages.BulkRequest, + "Static class 'BulkRequest' not defined"); + }); + + it("should include static class 'BulkResponse'", () => { + assert.ok(!!Messages.BulkResponse, + "Static class 'BulkResponse' not defined"); + }); - describe("SCIMMY.Messages", () => { - ErrorSuite(); - ListResponseSuite(); - PatchOpSuite(); - BulkRequestSuite(); - BulkResponseSuite(); - SearchRequestSuite(); + it("should include static class 'SearchRequest'", () => { + assert.ok(!!Messages.SearchRequest, + "Static class 'SearchRequest' not defined"); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index b432e48..6601316 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -3,6 +3,7 @@ import path from "path"; import url from "url"; import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +import {BulkRequest} from "#@/lib/messages/bulkrequest.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./bulkrequest.json"), "utf8").then((f) => JSON.parse(f)); @@ -47,291 +48,286 @@ class Test extends SCIMMY.Types.Resource { } } -export const BulkRequestSuite = () => { - it("should include static class 'BulkRequest'", () => - assert.ok(!!SCIMMY.Messages.BulkRequest, "Static class 'BulkRequest' not defined")); - - describe("SCIMMY.Messages.BulkRequest", () => { - describe("#constructor", () => { - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkRequest instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, - "BulkRequest instantiated with invalid 'schemas' property"); - }); +describe("SCIMMY.Messages.BulkRequest", () => { + describe("@constructor", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new BulkRequest({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new BulkRequest({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `BulkRequest request body messages must exclusively specify schema as '${params.id}'`}, + "BulkRequest instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new BulkRequest({schemas: template.schemas, Operations: "a string"}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, + "BulkRequest instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + + it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { + assert.throws(() => new BulkRequest({schemas: template.schemas}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkRequest instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new BulkRequest({schemas: template.schemas, Operations: []}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, + "BulkRequest instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; - it("should expect 'Operations' attribute of 'request' argument to be an array", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: "a string"}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest expected 'Operations' attribute of 'request' parameter to be an array"}, - "BulkRequest instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); - }); + for (let [label, value] of fixtures) { + assert.throws(() => new BulkRequest({...template, failOnErrors: value}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, + `BulkRequest instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); + } + }); + + it("should expect 'maxOperations' argument to be a positive integer, if specified", () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; - it("should expect at least one bulk op in 'Operations' attribute of 'request' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkRequest instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.BulkRequest({schemas: template.schemas, Operations: []}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "BulkRequest request body must contain 'Operations' attribute with at least one operation"}, - "BulkRequest instantiated without at least one bulk op in 'Operations' attribute of 'request' parameter"); - }); + for (let [label, value] of fixtures) { + assert.throws(() => new BulkRequest({...template}, value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, + `BulkRequest instantiated with invalid 'maxOperations' parameter ${label}`); + } + }); + + it("should expect number of operations to not exceed 'maxOperations' argument", () => { + assert.throws(() => new BulkRequest({...template}, 1), + {name: "SCIMError", status: 413, scimType: null, + message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, + "BulkRequest instantiated with number of operations exceeding 'maxOperations' parameter"); + }); + }); + + describe("#apply()", () => { + it("should have instance method 'apply'", () => { + assert.ok(typeof (new BulkRequest({...template})).apply === "function", + "Instance method 'apply' not defined"); + }); + + it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { + await assert.rejects(() => new BulkRequest({...template, failOnErrors: 1}).apply([{}]), + {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkRequest"}, + "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); + }); + + it("should expect 'method' attribute to have a value for each operation", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations; + const expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) + }})); - it("should expect 'failOnErrors' attribute of 'request' argument to be a positive integer, if specified", () => { - const fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: value}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "BulkRequest expected 'failOnErrors' attribute of 'request' parameter to be a positive integer"}, - `BulkRequest instantiated with invalid 'failOnErrors' attribute ${label} of 'request' parameter`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'method' attribute to be present for each operation"); + }); + + it("should expect 'method' attribute to be a string for each operation", async () => { + const fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; - it("should expect 'maxOperations' argument to be a positive integer, if specified", () => { - const fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; + for (let [label, value] of fixtures) { + const actual = (await (new BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations; + const expected = [{status: "400", method: value, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) + }}]; - for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "BulkRequest expected 'maxOperations' parameter to be a positive integer"}, - `BulkRequest instantiated with invalid 'maxOperations' parameter ${label}`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + `Instance method 'apply' did not reject 'method' attribute ${label}`); + } + }); + + it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations; + const expected = [{status: "400", method: "a string", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) + }}]; - it("should expect number of operations to not exceed 'maxOperations' argument", () => { - assert.throws(() => new SCIMMY.Messages.BulkRequest({...template}, 1), - {name: "SCIMError", status: 413, scimType: null, - message: "Number of operations in BulkRequest exceeds maxOperations limit (1)"}, - "BulkRequest instantiated with number of operations exceeding 'maxOperations' parameter"); - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not reject invalid 'method' string value 'a string'"); }); - describe("#apply()", () => { - it("should have instance method 'apply'", () => { - assert.ok(typeof (new SCIMMY.Messages.BulkRequest({...template})).apply === "function", - "Instance method 'apply' not defined"); - }); + it("should expect 'path' attribute to have a value for each operation", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) + }})); - it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { - await assert.rejects(() => new SCIMMY.Messages.BulkRequest({...template, failOnErrors: 1}).apply([{}]), - {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of BulkRequest"}, - "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to be present for each operation"); + }); + + it("should expect 'path' attribute to be a string for each operation", async () => { + const fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; - it("should expect 'method' attribute to have a value for each operation", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations; - const expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) - }})); + for (let [label, value] of fixtures) { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) + }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'method' attribute to be present for each operation"); - }); + `Instance method 'apply' did not reject 'path' attribute ${label}`); + } + }); + + it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) + }}]; - it("should expect 'method' attribute to be a string for each operation", async () => { - const fixtures = [ - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - - for (let [label, value] of fixtures) { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations; - const expected = [{status: "400", method: value, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) - }}]; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - `Instance method 'apply' did not reject 'method' attribute ${label}`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to refer to a valid resource type endpoint"); + }); + + it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations; + const expected = [{status: "404", method: "POST", bulkId: "asdf", response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) + }}]; - it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations; - const expected = [{status: "400", method: "a string", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) - }}]; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not reject invalid 'method' string value 'a string'"); - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute not to specify a resource ID when 'method' was POST"); + }); + + it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations; + const expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) + }})); - it("should expect 'path' attribute to have a value for each operation", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations; - const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) - }})); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'path' attribute to be present for each operation"); - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'path' attribute to specify a resource ID when 'method' was not POST"); + }); + + it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) + }})); - it("should expect 'path' attribute to be a string for each operation", async () => { - const fixtures = [ - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - - for (let [label, value] of fixtures) { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations; - const expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) - }}]; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - `Instance method 'apply' did not reject 'path' attribute ${label}`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'bulkId' attribute to be present for each 'POST' operation"); + }); + + it("should expect 'bulkId' attribute to be a string for each 'POST' operation", async () => { + const fixtures = [ + ["boolean value 'false'", false], + ["negative integer value '-1'", -1], + ["complex value", {}] + ]; - it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations; + for (let [label, value] of fixtures) { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations; const expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) - }}]; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'path' attribute to refer to a valid resource type endpoint"); - }); - - it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations; - const expected = [{status: "404", method: "POST", bulkId: "asdf", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( + 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'path' attribute not to specify a resource ID when 'method' was POST"); - }); - - it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations; - const expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) - }})); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'path' attribute to specify a resource ID when 'method' was not POST"); - }); + `Instance method 'apply' did not reject 'path' attribute ${label}`); + } + }); + + it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { + ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) + }})); - it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations; - const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) - }})); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'bulkId' attribute to be present for each 'POST' operation"); - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'apply' did not expect 'data' attribute to be present when 'method' was not DELETE"); + }); + + it("should expect 'data' attribute to be a single complex value when 'method' is not DELETE", async () => { + const suite = [ + {method: "POST", path: "/Test", bulkId: "asdf"}, + {method: "PUT", path: "/Test/1"}, + {method: "PATCH", path: "/Test/1"} + ]; + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["negative integer value '-1'", -1] + ]; - it("should expect 'bulkId' attribute to be a string for each 'POST' operation", async () => { - const fixtures = [ - ["boolean value 'false'", false], - ["negative integer value '-1'", -1], - ["complex value", {}] - ]; - + for (let op of suite) { for (let [label, value] of fixtures) { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations; - const expected = [{status: "400", method: "POST", response: { + const actual = (await (new BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) + 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - `Instance method 'apply' did not reject 'path' attribute ${label}`); + `Instance method 'apply' did not reject 'data' attribute ${label}`); } - }); - - it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations; - const expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) - }})); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - "Instance method 'apply' did not expect 'data' attribute to be present when 'method' was not DELETE"); - }); + } + }); + + it("should stop processing operations when failOnErrors limit is reached", async () => { + const {inbound: {failOnErrors: suite}} = await fixtures; - it("should expect 'data' attribute to be a single complex value when 'method' is not DELETE", async () => { - const suite = [ - {method: "POST", path: "/Test", bulkId: "asdf"}, - {method: "PUT", path: "/Test/1"}, - {method: "PATCH", path: "/Test/1"} - ]; - const fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["negative integer value '-1'", -1] - ]; - - for (let op of suite) { - for (let [label, value] of fixtures) { - const actual = (await (new SCIMMY.Messages.BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations; - const expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) - }}]; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, - `Instance method 'apply' did not reject 'data' attribute ${label}`); - } - } - }); + assert.ok((await (new BulkRequest({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, + "Instance method 'apply' did not stop processing when failOnErrors limit reached"); - it("should stop processing operations when failOnErrors limit is reached", async () => { - const {inbound: {failOnErrors: suite}} = await fixtures; - - assert.ok((await (new SCIMMY.Messages.BulkRequest({...template, failOnErrors: 1})).apply())?.Operations?.length === 1, - "Instance method 'apply' did not stop processing when failOnErrors limit reached"); + for (let fixture of suite) { + const result = await (new BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - for (let fixture of suite) { - const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - - assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, - `Instance method 'apply' did not stop processing in inbound failOnErrors fixture #${suite.indexOf(fixture)+1}`); - } - }); + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not stop processing in inbound failOnErrors fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should resolve bulkId references that are out of order", async () => { + const {inbound: {bulkId: {unordered: suite}}} = await fixtures; - it("should resolve bulkId references that are out of order", async () => { - const {inbound: {bulkId: {unordered: suite}}} = await fixtures; + for (let fixture of suite) { + const result = await (new BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - for (let fixture of suite) { - const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - - assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, - `Instance method 'apply' did not resolve references in inbound bulkId unordered fixture #${suite.indexOf(fixture)+1}`); - } - }); + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not resolve references in inbound bulkId unordered fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should resolve bulkId references that are circular", async () => { + const {inbound: {bulkId: {circular: suite}}} = await fixtures; - it("should resolve bulkId references that are circular", async () => { - const {inbound: {bulkId: {circular: suite}}} = await fixtures; + for (let fixture of suite) { + const result = await (new BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - for (let fixture of suite) { - const result = await (new SCIMMY.Messages.BulkRequest({...template, ...fixture.source})).apply([Test.reset()]); - - assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, - `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); - } - }); + assert.deepStrictEqual(result.Operations.map(r => r.status), fixture.target, + `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); + } }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js index 8b8b7ba..d52bd1d 100644 --- a/test/lib/messages/bulkresponse.js +++ b/test/lib/messages/bulkresponse.js @@ -1,46 +1,41 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {BulkResponse} from "#@/lib/messages/bulkresponse.js"; const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; const template = {schemas: [params.id], Operations: []}; -export const BulkResponseSuite = () => { - it("should include static class 'BulkResponse'", () => - assert.ok(!!SCIMMY.Messages.BulkResponse, "Static class 'BulkResponse' not defined")); +describe("SCIMMY.Messages.BulkResponse", () => { + describe("@constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new BulkResponse())}, template, + "BulkResponse did not instantiate with correct default properties"); + }); + + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new BulkResponse({schemas: ["nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + assert.throws(() => new BulkResponse({schemas: [params.id, "nonsense"]}), + {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, + "BulkResponse instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' argument to be an array", () => { + assert.throws(() => new BulkResponse({schemas: template.schemas, Operations: "a string"}), + {name: "TypeError", message: "BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"}, + "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + }); - describe("SCIMMY.Messages.BulkResponse", () => { - describe("#constructor", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.BulkResponse())}, template, - "BulkResponse did not instantiate with correct default properties"); - }); - - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: ["nonsense"]}), - {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, - "BulkResponse instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: [params.id, "nonsense"]}), - {name: "TypeError", message: `BulkResponse request body messages must exclusively specify schema as '${params.id}'`}, - "BulkResponse instantiated with invalid 'schemas' property"); - }); - - it("should expect 'Operations' attribute of 'request' argument to be an array", () => { - assert.throws(() => new SCIMMY.Messages.BulkResponse({schemas: template.schemas, Operations: "a string"}), - {name: "TypeError", message: "BulkResponse constructor expected 'Operations' property of 'request' parameter to be an array"}, - "BulkResponse instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); - }); + describe("#resolve()", () => { + it("should have instance method 'resolve'", () => { + assert.ok(typeof (new BulkResponse()).resolve === "function", + "Instance method 'resolve' not defined"); }); - describe("#resolve()", () => { - it("should have instance method 'resolve'", () => { - assert.ok(typeof (new SCIMMY.Messages.BulkResponse()).resolve === "function", - "Instance method 'resolve' not defined"); - }); - - it("should return an instance of native Map class", () => { - assert.ok((new SCIMMY.Messages.BulkResponse().resolve()) instanceof Map, - "Instance method 'resolve' did not return a map"); - }); + it("should return an instance of native Map class", () => { + assert.ok((new BulkResponse().resolve()) instanceof Map, + "Instance method 'resolve' did not return a map"); }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/error.js b/test/lib/messages/error.js index e899845..08fe2dc 100644 --- a/test/lib/messages/error.js +++ b/test/lib/messages/error.js @@ -2,63 +2,58 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Error as ErrorMessage} from "#@/lib/messages/error.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./error.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:Error"}; const template = {schemas: [params.id], status: "500"}; -export const ErrorSuite = () => { - it("should include static class 'Error'", () => - assert.ok(!!SCIMMY.Messages.Error, "Static class 'Error' not defined")); - - describe("SCIMMY.Messages.Error", () => { - describe("#constructor", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.Error())}, template, - "SCIM Error message did not instantiate with correct default properties"); - }); - - it("should rethrow inbound SCIM Error messages at instantiation", async () => { - const {inbound: suite} = await fixtures; - - for (let fixture of suite) { - assert.throws(() => new SCIMMY.Messages.Error(fixture), - {name: "SCIMError", message: fixture.detail, status: fixture.status, scimType: fixture.scimType}, - `Inbound SCIM Error message fixture #${suite.indexOf(fixture)+1} not rethrown at instantiation`); - } - }); +describe("SCIMMY.Messages.Error", () => { + describe("@constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new ErrorMessage())}, template, + "SCIM Error message did not instantiate with correct default properties"); + }); + + it("should rethrow inbound SCIM Error messages at instantiation", async () => { + const {inbound: suite} = await fixtures; - it("should not accept invalid HTTP status codes for 'status' parameter", () => { - assert.throws(() => new SCIMMY.Messages.Error({status: "a string"}), - {name: "TypeError", message: "Incompatible HTTP status code 'a string' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'status' parameter 'a string'"); - assert.throws(() => new SCIMMY.Messages.Error({status: 402}), - {name: "TypeError", message: "Incompatible HTTP status code '402' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'status' parameter '402'"); - }); + for (let fixture of suite) { + assert.throws(() => new ErrorMessage(fixture), + {name: "SCIMError", message: fixture.detail, status: fixture.status, scimType: fixture.scimType}, + `Inbound SCIM Error message fixture #${suite.indexOf(fixture)+1} not rethrown at instantiation`); + } + }); + + it("should not accept invalid HTTP status codes for 'status' parameter", () => { + assert.throws(() => new ErrorMessage({status: "a string"}), + {name: "TypeError", message: "Incompatible HTTP status code 'a string' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'status' parameter 'a string'"); + assert.throws(() => new ErrorMessage({status: 402}), + {name: "TypeError", message: "Incompatible HTTP status code '402' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'status' parameter '402'"); + }); + + it("should not accept unknown values for 'scimType' parameter", () => { + assert.throws(() => new ErrorMessage({scimType: "a string"}), + {name: "TypeError", message: "Unknown detail error keyword 'a string' supplied to SCIM Error Message constructor"}, + "Error message instantiated with invalid 'scimType' parameter 'a string'"); + }); + + it("should verify 'scimType' value is valid for a given 'status' code", async () => { + const {outbound: {valid, invalid}} = await fixtures; - it("should not accept unknown values for 'scimType' parameter", () => { - assert.throws(() => new SCIMMY.Messages.Error({scimType: "a string"}), - {name: "TypeError", message: "Unknown detail error keyword 'a string' supplied to SCIM Error Message constructor"}, - "Error message instantiated with invalid 'scimType' parameter 'a string'"); - }); + for (let fixture of valid) { + assert.deepStrictEqual({...(new ErrorMessage(fixture))}, {...template, ...fixture}, + `Error message type check 'valid' fixture #${valid.indexOf(fixture) + 1} did not produce expected output`); + } - it("should verify 'scimType' value is valid for a given 'status' code", async () => { - const {outbound: {valid, invalid}} = await fixtures; - - for (let fixture of valid) { - assert.deepStrictEqual({...(new SCIMMY.Messages.Error(fixture))}, {...template, ...fixture}, - `Error message type check 'valid' fixture #${valid.indexOf(fixture) + 1} did not produce expected output`); - } - - for (let fixture of invalid) { - assert.throws(() => new SCIMMY.Messages.Error(fixture), - {name: "TypeError", message: `HTTP status code '${fixture.status}' not valid for detail error keyword '${fixture.scimType}' in SCIM Error Message constructor`}, - `Error message instantiated with invalid 'scimType' and 'status' parameters in type check 'invalid' fixture #${valid.indexOf(fixture) + 1}`); - } - }); + for (let fixture of invalid) { + assert.throws(() => new ErrorMessage(fixture), + {name: "TypeError", message: `HTTP status code '${fixture.status}' not valid for detail error keyword '${fixture.scimType}' in SCIM Error Message constructor`}, + `Error message instantiated with invalid 'scimType' and 'status' parameters in type check 'invalid' fixture #${valid.indexOf(fixture) + 1}`); + } }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/listresponse.js b/test/lib/messages/listresponse.js index a1f32b8..970dab3 100644 --- a/test/lib/messages/listresponse.js +++ b/test/lib/messages/listresponse.js @@ -2,157 +2,152 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {ListResponse} from "#@/lib/messages/listresponse.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./listresponse.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:ListResponse"}; const template = {schemas: [params.id], Resources: [], totalResults: 0, startIndex: 1, itemsPerPage: 20}; -export const ListResponseSuite = () => { - it("should include static class 'ListResponse'", () => - assert.ok(!!SCIMMY.Messages.ListResponse, "Static class 'ListResponse' not defined")); +describe("SCIMMY.Messages.ListResponse", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new ListResponse())}, template, + "ListResponse did not instantiate with correct default properties"); + }); - describe("SCIMMY.Messages.ListResponse", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.ListResponse())}, template, - "ListResponse did not instantiate with correct default properties"); - }); - - it("should not accept requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.ListResponse({schemas: ["nonsense"]}), - {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, - "ListResponse instantiated with invalid 'schemas' property"); - assert.throws(() => - new SCIMMY.Messages.ListResponse({schemas: [params.id, "nonsense"]}), - {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, - "ListResponse instantiated with invalid 'schemas' property"); - }); - - it("should expect 'startIndex' parameter to be a positive integer", () => { - for (let value of ["a string", -1, 1.5]) { - assert.throws(() => new SCIMMY.Messages.ListResponse([], {startIndex: value}), - {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, - `ListResponse instantiated with invalid 'startIndex' parameter value '${value}'`); - } - }); - - it("should use 'startIndex' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; - - for (let fixture of suite) { - assert.ok((new SCIMMY.Messages.ListResponse(fixture, {startIndex: 20})).startIndex === fixture.startIndex, - `ListResponse did not use 'startIndex' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } - }); - - it("should expect 'itemsPerPage' parameter to be a positive integer", () => { - for (let value of ["a string", -1, 1.5]) { - assert.throws(() => new SCIMMY.Messages.ListResponse([], {itemsPerPage: value}), - {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, - `ListResponse instantiated with invalid 'itemsPerPage' parameter value '${value}'`); - } - }); - - it("should use 'itemsPerPage' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; - - for (let fixture of suite) { - assert.ok((new SCIMMY.Messages.ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage === fixture.itemsPerPage, - `ListResponse did not use 'itemsPerPage' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } - }); - - it("should expect 'sortBy' parameter to be a string", () => { - assert.throws(() => new SCIMMY.Messages.ListResponse([], {sortBy: 1}), - {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, - "ListResponse instantiated with invalid 'sortBy' parameter value '1'"); - assert.throws(() => new SCIMMY.Messages.ListResponse([], {sortBy: {}}), - {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, - "ListResponse instantiated with invalid 'sortBy' parameter complex value"); - }); + it("should not accept requests with invalid schemas", () => { + assert.throws(() => new ListResponse({schemas: ["nonsense"]}), + {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, + "ListResponse instantiated with invalid 'schemas' property"); + assert.throws(() => + new ListResponse({schemas: [params.id, "nonsense"]}), + {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, + "ListResponse instantiated with invalid 'schemas' property"); + }); + + it("should expect 'startIndex' parameter to be a positive integer", () => { + for (let value of ["a string", -1, 1.5]) { + assert.throws(() => new ListResponse([], {startIndex: value}), + {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, + `ListResponse instantiated with invalid 'startIndex' parameter value '${value}'`); + } + }); + + it("should use 'startIndex' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; - it("should ignore 'sortOrder' parameter if 'sortBy' parameter is not defined", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.ListResponse([], {sortOrder: "a string"}), - "ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined"); - }); + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {startIndex: 20})).startIndex === fixture.startIndex, + `ListResponse did not use 'startIndex' value included in inbound fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should expect 'itemsPerPage' parameter to be a positive integer", () => { + for (let value of ["a string", -1, 1.5]) { + assert.throws(() => new ListResponse([], {itemsPerPage: value}), + {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, + `ListResponse instantiated with invalid 'itemsPerPage' parameter value '${value}'`); + } + }); + + it("should use 'itemsPerPage' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; - it("should expect 'sortOrder' parameter to be either 'ascending' or 'descending' if 'sortBy' parameter is defined", () => { - assert.throws(() => new SCIMMY.Messages.ListResponse([], {sortBy: "test", sortOrder: "a string"}), - {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in ListResponse message constructor"}, - "ListResponse accepted invalid 'sortOrder' parameter value 'a string'"); - }); + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage === fixture.itemsPerPage, + `ListResponse did not use 'itemsPerPage' value included in inbound fixture #${suite.indexOf(fixture)+1}`); + } + }); + + it("should expect 'sortBy' parameter to be a string", () => { + assert.throws(() => new ListResponse([], {sortBy: 1}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, + "ListResponse instantiated with invalid 'sortBy' parameter value '1'"); + assert.throws(() => new ListResponse([], {sortBy: {}}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, + "ListResponse instantiated with invalid 'sortBy' parameter complex value"); + }); + + it("should ignore 'sortOrder' parameter if 'sortBy' parameter is not defined", () => { + assert.doesNotThrow(() => new ListResponse([], {sortOrder: "a string"}), + "ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined"); + }); + + it("should expect 'sortOrder' parameter to be either 'ascending' or 'descending' if 'sortBy' parameter is defined", () => { + assert.throws(() => new ListResponse([], {sortBy: "test", sortOrder: "a string"}), + {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in ListResponse message constructor"}, + "ListResponse accepted invalid 'sortOrder' parameter value 'a string'"); + }); + + it("should have instance member 'Resources' that is an array", () => { + const list = new ListResponse(); - it("should have instance member 'Resources' that is an array", () => { - const list = new SCIMMY.Messages.ListResponse(); - - assert.ok("Resources" in list, - "Instance member 'Resources' not defined"); - assert.ok(Array.isArray(list.Resources), - "Instance member 'Resources' was not an array"); - }); + assert.ok("Resources" in list, + "Instance member 'Resources' not defined"); + assert.ok(Array.isArray(list.Resources), + "Instance member 'Resources' was not an array"); + }); + + it("should have instance member 'totalResults' that is a non-negative integer", () => { + const list = new ListResponse(); - it("should have instance member 'totalResults' that is a non-negative integer", () => { - const list = new SCIMMY.Messages.ListResponse(); - - assert.ok("totalResults" in list, - "Instance member 'totalResults' not defined"); - assert.ok(typeof list.totalResults === "number" && !Number.isNaN(list.totalResults), - "Instance member 'totalResults' was not a number"); - assert.ok(list.totalResults >= 0 && Number.isInteger(list.totalResults), - "Instance member 'totalResults' was not a non-negative integer"); - }); + assert.ok("totalResults" in list, + "Instance member 'totalResults' not defined"); + assert.ok(typeof list.totalResults === "number" && !Number.isNaN(list.totalResults), + "Instance member 'totalResults' was not a number"); + assert.ok(list.totalResults >= 0 && Number.isInteger(list.totalResults), + "Instance member 'totalResults' was not a non-negative integer"); + }); + + it("should use 'totalResults' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; - it("should use 'totalResults' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {totalResults: 200})).totalResults === fixture.totalResults, + `ListResponse did not use 'totalResults' value included in inbound fixture #${suite.indexOf(fixture)+1}`); + } + }); + + for (let member of ["startIndex", "itemsPerPage"]) { + it(`should have instance member '${member}' that is a positive integer`, () => { + const list = new ListResponse(); - for (let fixture of suite) { - assert.ok((new SCIMMY.Messages.ListResponse(fixture, {totalResults: 200})).totalResults === fixture.totalResults, - `ListResponse did not use 'totalResults' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } + assert.ok(member in list, + `Instance member '${member}' not defined`); + assert.ok(typeof list[member] === "number" && !Number.isNaN(list[member]), + `Instance member '${member}' was not a number`); + assert.ok(list[member] > 0 && Number.isInteger(list[member]), + `Instance member '${member}' was not a positive integer`); }); + } + + it("should only sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source}} = await fixtures; + const list = new ListResponse(source, {sortOrder: "descending"}); - for (let member of ["startIndex", "itemsPerPage"]) { - it(`should have instance member '${member}' that is a positive integer`, () => { - const list = new SCIMMY.Messages.ListResponse(); - - assert.ok(member in list, - `Instance member '${member}' not defined`); - assert.ok(typeof list[member] === "number" && !Number.isNaN(list[member]), - `Instance member '${member}' was not a number`); - assert.ok(list[member] > 0 && Number.isInteger(list[member]), - `Instance member '${member}' was not a positive integer`); - }); + for (let item of source) { + assert.ok(item.id === list.Resources[source.indexOf(item)]?.id, + "ListResponse unexpectedly sorted resources when 'sortBy' parameter was not supplied"); } + }); + + it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source, targets: suite}} = await fixtures; - it("should only sort resources if 'sortBy' parameter is supplied", async () => { - const {outbound: {source}} = await fixtures; - const list = new SCIMMY.Messages.ListResponse(source, {sortOrder: "descending"}); - - for (let item of source) { - assert.ok(item.id === list.Resources[source.indexOf(item)]?.id, - "ListResponse unexpectedly sorted resources when 'sortBy' parameter was not supplied"); - } - }); - - it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { - const {outbound: {source, targets: suite}} = await fixtures; + for (let fixture of suite) { + const list = new ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); - for (let fixture of suite) { - const list = new SCIMMY.Messages.ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); - - assert.deepStrictEqual(list.Resources.map(r => r.id), fixture.expected, - `ListResponse did not correctly sort outbound target #${suite.indexOf(fixture)+1} by 'sortBy' value '${fixture.sortBy}'`); - } - }); + assert.deepStrictEqual(list.Resources.map(r => r.id), fixture.expected, + `ListResponse did not correctly sort outbound target #${suite.indexOf(fixture)+1} by 'sortBy' value '${fixture.sortBy}'`); + } + }); + + it("should not include more resources than 'itemsPerPage' parameter", async () => { + const {outbound: {source}} = await fixtures; - it("should not include more resources than 'itemsPerPage' parameter", async () => { - const {outbound: {source}} = await fixtures; - - for (let length of [2, 5, 10, 200, 1]) { - assert.ok((new SCIMMY.Messages.ListResponse(source, {itemsPerPage: length})).Resources.length <= length, - "ListResponse included more resources than specified in 'itemsPerPage' parameter"); - } - }); + for (let length of [2, 5, 10, 200, 1]) { + assert.ok((new ListResponse(source, {itemsPerPage: length})).Resources.length <= length, + "ListResponse included more resources than specified in 'itemsPerPage' parameter"); + } }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index fa34c68..b5179a2 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -3,205 +3,201 @@ import path from "path"; import url from "url"; import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +import {PatchOp} from "#@/lib/messages/patchop.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./patchop.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:PatchOp"}; const template = {schemas: [params.id]}; -export const PatchOpSuite = () => { - it("should include static class 'PatchOp'", () => - assert.ok(!!SCIMMY.Messages.PatchOp, "Static class 'PatchOp' not defined")); - - describe("SCIMMY.Messages.PatchOp", () => { - describe("#constructor", () => { - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, - "PatchOp instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, - "PatchOp instantiated with invalid 'schemas' property"); - }); - - it("should expect 'Operations' attribute of 'request' parameter to be an array", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: "a string"}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp expects 'Operations' attribute of 'request' parameter to be an array"}, - "PatchOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); - }); - - it("should expect at least one patch op in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, - "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: []}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, - "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all patch ops to be 'complex' values in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, "a string"]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'string'"}, - `PatchOp instantiated with invalid patch op 'a string' in 'Operations' attribute of 'request' parameter`); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, true]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'boolean'"}, - `PatchOp instantiated with invalid patch op 'true' in 'Operations' attribute of 'request' parameter`); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, []]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'collection'"}, - `PatchOp instantiated with invalid patch op '[]' in 'Operations' attribute of 'request' parameter`); - }); - - it("should expect all patch ops to have an 'op' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'op' from operation 1 in PatchOp request body"}, - "PatchOp instantiated with invalid patch op '{}' in 'Operations' attribute of 'request' parameter"); - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {value: "a string"}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'op' from operation 2 in PatchOp request body"}, - `PatchOp instantiated with invalid patch op '{value: "a string"}' in 'Operations' attribute of 'request' parameter`); - }); - - it("should not accept unknown 'op' values in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "a string"}]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Invalid operation 'a string' for operation 1 in PatchOp request body"}, - "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); - }); - - it("should ignore case of 'op' values in 'Operations' attribute of 'request' parameter", () => { - try { - new SCIMMY.Messages.PatchOp({...template, Operations: [ - {op: "Add", value: {}}, {op: "ADD", value: {}}, {op: "aDd", value: {}}, - {op: "Remove", path: "test"}, {op: "REMOVE", path: "test"}, {op: "rEmOvE", path: "test"}, - {op: "Replace", value: {}}, {op: "REPLACE", value: {}}, {op: "rEpLaCe", value: {}}, - ]}); - } catch (ex) { - if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { - const op = ex.message.replace("Invalid operation '").split("'").unshift(); - assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); - } - } - }); - - it("should expect all 'add' ops to have a 'value' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {op: "add", value: false}, {op: "add", path: "test"}]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Missing required attribute 'value' for 'add' op of operation 3 in PatchOp request body"}, - "PatchOp instantiated with missing 'value' value for 'add' op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all 'remove' ops to have a 'path' value in 'Operations' attribute of 'request' parameter", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "remove", path: "test"}, {op: "remove"}]}), - {name: "SCIMError", status: 400, scimType: "noTarget", - message: "Missing required attribute 'path' for 'remove' op of operation 2 in PatchOp request body"}, - "PatchOp instantiated with missing 'path' value for 'remove' op in 'Operations' attribute of 'request' parameter"); - }); - - it("should expect all patch op 'path' values to be strings in 'Operations' attribute of 'request' parameter", () => { - const operations = [ - {op: "remove", path: 1}, - {op: "remove", path: true}, - {op: "add", value: 1, path: false} - ]; - - for (let op of operations) { - assert.throws(() => new SCIMMY.Messages.PatchOp({...template, Operations: [op]}), - {name: "SCIMError", status: 400, scimType: "invalidPath", - message: `Invalid path '${op.path}' for operation 1 in PatchOp request body`}, - `PatchOp instantiated with invalid 'path' value '${op.path}' in 'Operations' attribute of 'request' parameter`); +describe("SCIMMY.Messages.PatchOp", () => { + describe("@constructor", () => { + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new PatchOp({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, + "PatchOp instantiated with invalid 'schemas' property"); + assert.throws(() => new PatchOp({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `PatchOp request body messages must exclusively specify schema as '${params.id}'`}, + "PatchOp instantiated with invalid 'schemas' property"); + }); + + it("should expect 'Operations' attribute of 'request' parameter to be an array", () => { + assert.throws(() => new PatchOp({...template, Operations: "a string"}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp expects 'Operations' attribute of 'request' parameter to be an array"}, + "PatchOp instantiated with invalid 'Operations' attribute value 'a string' of 'request' parameter"); + }); + + it("should expect at least one patch op in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, + "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new PatchOp({...template, Operations: []}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body must contain 'Operations' attribute with at least one operation"}, + "PatchOp instantiated without at least one patch op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all patch ops to be 'complex' values in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{op: "add", value: {}}, "a string"]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'string'"}, + `PatchOp instantiated with invalid patch op 'a string' in 'Operations' attribute of 'request' parameter`); + assert.throws(() => new PatchOp({...template, Operations: [{op: "add", value: {}}, true]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'boolean'"}, + `PatchOp instantiated with invalid patch op 'true' in 'Operations' attribute of 'request' parameter`); + assert.throws(() => new PatchOp({...template, Operations: [{op: "add", value: {}}, []]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "PatchOp request body expected value type 'complex' for operation 2 but found type 'collection'"}, + `PatchOp instantiated with invalid patch op '[]' in 'Operations' attribute of 'request' parameter`); + }); + + it("should expect all patch ops to have an 'op' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'op' from operation 1 in PatchOp request body"}, + "PatchOp instantiated with invalid patch op '{}' in 'Operations' attribute of 'request' parameter"); + assert.throws(() => new PatchOp({...template, Operations: [{op: "add", value: {}}, {value: "a string"}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'op' from operation 2 in PatchOp request body"}, + `PatchOp instantiated with invalid patch op '{value: "a string"}' in 'Operations' attribute of 'request' parameter`); + }); + + it("should not accept unknown 'op' values in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{op: "a string"}]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Invalid operation 'a string' for operation 1 in PatchOp request body"}, + "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); + }); + + it("should ignore case of 'op' values in 'Operations' attribute of 'request' parameter", () => { + try { + new PatchOp({...template, Operations: [ + {op: "Add", value: {}}, {op: "ADD", value: {}}, {op: "aDd", value: {}}, + {op: "Remove", path: "test"}, {op: "REMOVE", path: "test"}, {op: "rEmOvE", path: "test"}, + {op: "Replace", value: {}}, {op: "REPLACE", value: {}}, {op: "rEpLaCe", value: {}}, + ]}); + } catch (ex) { + if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { + const op = ex.message.replace("Invalid operation '").split("'").unshift(); + assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); } - }); + } }); - describe("#apply()", () => { - it("should have instance method 'apply'", () => { - assert.ok(typeof (new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}]})).apply === "function", - "Instance method 'apply' not defined"); - }); - - it("should expect message to be dispatched before 'apply' is called", async () => { - await assert.rejects(() => new SCIMMY.Messages.PatchOp().apply(), - {name: "TypeError", message: "PatchOp expected message to be dispatched before calling 'apply' method"}, - "PatchOp did not expect message to be dispatched before proceeding with 'apply' method"); - }); + it("should expect all 'add' ops to have a 'value' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{op: "add", value: {}}, {op: "add", value: false}, {op: "add", path: "test"}]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Missing required attribute 'value' for 'add' op of operation 3 in PatchOp request body"}, + "PatchOp instantiated with missing 'value' value for 'add' op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all 'remove' ops to have a 'path' value in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{op: "remove", path: "test"}, {op: "remove"}]}), + {name: "SCIMError", status: 400, scimType: "noTarget", + message: "Missing required attribute 'path' for 'remove' op of operation 2 in PatchOp request body"}, + "PatchOp instantiated with missing 'path' value for 'remove' op in 'Operations' attribute of 'request' parameter"); + }); + + it("should expect all patch op 'path' values to be strings in 'Operations' attribute of 'request' parameter", () => { + const operations = [ + {op: "remove", path: 1}, + {op: "remove", path: true}, + {op: "add", value: 1, path: false} + ]; - it("should expect 'resource' parameter to be defined", async () => { - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(), + for (let op of operations) { + assert.throws(() => new PatchOp({...template, Operations: [op]}), + {name: "SCIMError", status: 400, scimType: "invalidPath", + message: `Invalid path '${op.path}' for operation 1 in PatchOp request body`}, + `PatchOp instantiated with invalid 'path' value '${op.path}' in 'Operations' attribute of 'request' parameter`); + } + }); + }); + + describe("#apply()", () => { + it("should have instance method 'apply'", () => { + assert.ok(typeof (new PatchOp({...template, Operations: [{op: "add", value: {}}]})).apply === "function", + "Instance method 'apply' not defined"); + }); + + it("should expect message to be dispatched before 'apply' is called", async () => { + await assert.rejects(() => new PatchOp().apply(), + {name: "TypeError", message: "PatchOp expected message to be dispatched before calling 'apply' method"}, + "PatchOp did not expect message to be dispatched before proceeding with 'apply' method"); + }); + + it("should expect 'resource' parameter to be defined", async () => { + await assert.rejects(() => new PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(), + {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, + "PatchOp did not expect 'resource' parameter to be defined in 'apply' method"); + }); + + it("should expect 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { + for (let value of [{}, new Date()]) { + await assert.rejects(() => new PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(value), {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, - "PatchOp did not expect 'resource' parameter to be defined in 'apply' method"); - }); - - it("should expect 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { - for (let value of [{}, new Date()]) { - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(value), - {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, - "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); - } - }); - - for (let op of ["add", "remove", "replace"]) { - it(`should support simple and complex '${op}' operations`, async () => { - const {inbound: {[op]: suite}} = await fixtures; - - for (let fixture of suite) { - const message = new SCIMMY.Messages.PatchOp({...template, Operations: fixture.ops}); - const source = new SCIMMY.Schemas.User(fixture.source); - const expected = new SCIMMY.Schemas.User(fixture.target, "out"); - const actual = new SCIMMY.Schemas.User(await message.apply(source, (patched) => { - const expected = JSON.parse(JSON.stringify({...fixture.target, meta: undefined})); - const actual = JSON.parse(JSON.stringify({...patched, schemas: undefined, meta: undefined})); - - // Also make sure the resource is handled correctly during finalisation - assert.deepStrictEqual(actual, expected, - `PatchOp 'apply' patched resource unexpectedly in '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); - - return patched; - }), "out"); + "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); + } + }); + + for (let op of ["add", "remove", "replace"]) { + it(`should support simple and complex '${op}' operations`, async () => { + const {inbound: {[op]: suite}} = await fixtures; + + for (let fixture of suite) { + const message = new PatchOp({...template, Operations: fixture.ops}); + const source = new SCIMMY.Schemas.User(fixture.source); + const expected = new SCIMMY.Schemas.User(fixture.target, "out"); + const actual = new SCIMMY.Schemas.User(await message.apply(source, (patched) => { + const expected = JSON.parse(JSON.stringify({...fixture.target, meta: undefined})); + const actual = JSON.parse(JSON.stringify({...patched, schemas: undefined, meta: undefined})); + // Also make sure the resource is handled correctly during finalisation assert.deepStrictEqual(actual, expected, - `PatchOp 'apply' did not support '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); - } - }); - - if (["add", "replace"].includes(op)) { - it(`should expect 'value' to be an object when 'path' is not specified in '${op}' operations`, async () => { - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op, value: false}]}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: `Attribute 'value' must be an object when 'path' is empty for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); - }); - } - - it(`should respect attribute mutability in '${op}' operations`, async () => { - const Operations = [{op, path: "id", ...(op === "add" ? {value: "asdf"} : {})}]; - - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not respect attribute mutability in '${op}' operations`); - }); - - it(`should not remove required attributes in '${op}' operations`, async () => { - const Operations = [{op, path: "userName", ...(op === "add" ? {value: null} : {})}]; + `PatchOp 'apply' patched resource unexpectedly in '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); + + return patched; + }), "out"); - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations}) + assert.deepStrictEqual(actual, expected, + `PatchOp 'apply' did not support '${op}' op specified in inbound fixture ${suite.indexOf(fixture) + 1}`); + } + }); + + if (["add", "replace"].includes(op)) { + it(`should expect 'value' to be an object when 'path' is not specified in '${op}' operations`, async () => { + await assert.rejects(() => new PatchOp({...template, Operations: [{op, value: false}]}) .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), {name: "SCIMError", status: 400, scimType: "invalidValue", - message: `Required attribute 'userName' is missing for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp removed required attributes in '${op}' operations`); + message: `Attribute 'value' must be an object when 'path' is empty for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); }); } - }); + + it(`should respect attribute mutability in '${op}' operations`, async () => { + const Operations = [{op, path: "id", ...(op === "add" ? {value: "asdf"} : {})}]; + + await assert.rejects(() => new PatchOp({...template, Operations}) + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not respect attribute mutability in '${op}' operations`); + }); + + it(`should not remove required attributes in '${op}' operations`, async () => { + const Operations = [{op, path: "userName", ...(op === "add" ? {value: null} : {})}]; + + await assert.rejects(() => new PatchOp({...template, Operations}) + .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Required attribute 'userName' is missing for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp removed required attributes in '${op}' operations`); + }); + } }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index 9c140d1..a05f9fd 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -1,5 +1,6 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +import {SearchRequest} from "#@/lib/messages/searchrequest.js"; const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; const template = {schemas: [params.id]}; @@ -26,220 +27,215 @@ const suites = { ] }; -export const SearchRequestSuite = () => { - it("should include static class 'SearchRequest'", () => - assert.ok(!!SCIMMY.Messages.SearchRequest, "Static class 'SearchRequest' not defined")); +describe("SCIMMY.Messages.SearchRequest", () => { + describe("#constructor", () => { + it("should not require arguments at instantiation", () => { + assert.deepStrictEqual({...(new SearchRequest())}, template, + "SearchRequest did not instantiate with correct default properties"); + }); + + it("should not instantiate requests with invalid schemas", () => { + assert.throws(() => new SearchRequest({schemas: ["nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + assert.throws(() => new SearchRequest({schemas: [params.id, "nonsense"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, + "SearchRequest instantiated with invalid 'schemas' property"); + }); + + it("should expect 'filter' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, filter: "test"}), + "SearchRequest did not instantiate with valid 'filter' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest({...template, filter: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'filter' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'filter' property ${label}`); + } + }); + + it("should expect 'excludedAttributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, excludedAttributes: ["test"]}), + "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SearchRequest({...template, excludedAttributes: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'sortBy' property of 'request' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, sortBy: "test"}), + "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest({...template, sortBy: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortBy' parameter to be a non-empty string"}, + `SearchRequest instantiated with invalid 'sortBy' property ${label}`); + } + }); + + it("should expect 'sortOrder' property of 'request' argument to be either 'ascending' or 'descending', if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, sortOrder: "ascending"}), + "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest({...template, sortOrder: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, + `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); + } + }); + + it("should expect 'startIndex' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, startIndex: 1}), + "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SearchRequest({...template, startIndex: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'startIndex' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'startIndex' property ${label}`); + } + }); + + it("should expect 'count' property of 'request' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, count: 1}), + "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SearchRequest({...template, count: value}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'count' parameter to be a positive integer"}, + `SearchRequest instantiated with invalid 'count' property ${label}`); + } + }); + }); - describe("SCIMMY.Messages.SearchRequest", () => { - describe("#constructor", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new SCIMMY.Messages.SearchRequest())}, template, - "SearchRequest did not instantiate with correct default properties"); - }); - - it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: ["nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, - "SearchRequest instantiated with invalid 'schemas' property"); - assert.throws(() => new SCIMMY.Messages.SearchRequest({schemas: [params.id, "nonsense"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `SearchRequest request body messages must exclusively specify schema as '${params.id}'`}, - "SearchRequest instantiated with invalid 'schemas' property"); - }); - - it("should expect 'filter' property of 'request' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, filter: "test"}), - "SearchRequest did not instantiate with valid 'filter' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, filter: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'filter' parameter to be a non-empty string"}, - `SearchRequest instantiated with invalid 'filter' property ${label}`); - } - }); - - it("should expect 'excludedAttributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), - "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, - `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); - } - }); - - it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: ["test"]}), - "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, excludedAttributes: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, - `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); - } - }); - - it("should expect 'sortBy' property of 'request' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: "test"}), - "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortBy: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'sortBy' parameter to be a non-empty string"}, - `SearchRequest instantiated with invalid 'sortBy' property ${label}`); - } - }); - - it("should expect 'sortOrder' property of 'request' argument to be either 'ascending' or 'descending', if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: "ascending"}), - "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, sortOrder: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, - `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); - } - }); - - it("should expect 'startIndex' property of 'request' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: 1}), - "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, startIndex: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'startIndex' parameter to be a positive integer"}, - `SearchRequest instantiated with invalid 'startIndex' property ${label}`); - } - }); - - it("should expect 'count' property of 'request' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest({...template, count: 1}), - "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest({...template, count: value}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'count' parameter to be a positive integer"}, - `SearchRequest instantiated with invalid 'count' property ${label}`); - } - }); - }); - - describe("#prepare()", () => { - it("should have instance method 'prepare'", () => { - assert.ok(typeof (new SCIMMY.Messages.SearchRequest()).prepare === "function", - "Instance method 'prepare' not defined"); - }); - - it("should return the same instance it was called from", () => { - let expected = new SCIMMY.Messages.SearchRequest(); - - assert.strictEqual(expected.prepare(), expected, - "Instance method 'prepare' did not return the same instance it was called from"); - }); - - it("should expect 'filter' property of 'params' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({filter: "test"}), - "Instance method 'prepare' rejected valid 'filter' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({filter: value}), - {name: "TypeError", message: "Expected 'filter' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'filter' property ${label}`); - } - }); - - it("should expect 'excludedAttributes' property of 'params' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({excludedAttributes: ["test"]}), - "Instance method 'prepare' rejected valid 'excludedAttributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({excludedAttributes: value}), - {name: "TypeError", message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'excludedAttributes' property ${label}`); - } - }); - - it("should expect 'attributes' property of 'request' params to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({attributes: ["test"]}), - "Instance method 'prepare' rejected valid 'attributes' property non-empty string array value"); - - for (let [label, value] of suites.arrays) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({attributes: value}), - {name: "TypeError", message: "Expected 'attributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'attributes' property ${label}`); - } - }); - - it("should expect 'sortBy' property of 'params' argument to be a non-empty string, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({sortBy: "test"}), - "Instance method 'prepare' rejected valid 'sortBy' property string value 'test'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({sortBy: value}), - {name: "TypeError", message: "Expected 'sortBy' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'sortBy' property ${label}`); - } - }); - - it("should expect 'sortOrder' property of 'params' argument to be either 'ascending' or 'descending', if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({sortOrder: "ascending"}), - "Instance method 'prepare' rejected valid 'sortOrder' property string value 'ascending'"); - - for (let [label, value] of suites.strings) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({sortOrder: value}), - {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'sortOrder' property ${label}`); - } - }); - - it("should expect 'startIndex' property of 'params' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({startIndex: 1}), - "Instance method 'prepare' rejected valid 'startIndex' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({startIndex: value}), - {name: "TypeError", message: "Expected 'startIndex' parameter to be a positive integer in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'startIndex' property ${label}`); - } - }); - - it("should expect 'count' property of 'params' argument to be a positive integer, if specified", () => { - assert.doesNotThrow(() => new SCIMMY.Messages.SearchRequest().prepare({count: 1}), - "Instance method 'prepare' rejected valid 'count' property positive integer value '1'"); - - for (let [label, value] of suites.numbers) { - assert.throws(() => new SCIMMY.Messages.SearchRequest().prepare({count: value}), - {name: "TypeError", message: "Expected 'count' parameter to be a positive integer in 'prepare' method of SearchRequest"}, - `Instance method 'prepare' did not reject invalid 'count' property ${label}`); - } - }); - }); - - describe("#apply()", () => { - it("should have instance method 'apply'", () => { - assert.ok(typeof (new SCIMMY.Messages.SearchRequest()).apply === "function", - "Instance method 'apply' not defined"); - }); - - it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { - await assert.rejects(() => new SCIMMY.Messages.SearchRequest().apply([{}]), - {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of SearchRequest"}, - "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); - }); + describe("#prepare()", () => { + it("should have instance method 'prepare'", () => { + assert.ok(typeof (new SearchRequest()).prepare === "function", + "Instance method 'prepare' not defined"); + }); + + it("should return the same instance it was called from", () => { + let expected = new SearchRequest(); - it("should return a ListResponse message instance", async () => { - assert.ok(await (new SCIMMY.Messages.SearchRequest()).apply() instanceof SCIMMY.Messages.ListResponse, - "Instance method 'apply' did not return an instance of ListResponse"); - }); + assert.strictEqual(expected.prepare(), expected, + "Instance method 'prepare' did not return the same instance it was called from"); + }); + + it("should expect 'filter' property of 'params' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({filter: "test"}), + "Instance method 'prepare' rejected valid 'filter' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({filter: value}), + {name: "TypeError", message: "Expected 'filter' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'filter' property ${label}`); + } + }); + + it("should expect 'excludedAttributes' property of 'params' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({excludedAttributes: ["test"]}), + "Instance method 'prepare' rejected valid 'excludedAttributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SearchRequest().prepare({excludedAttributes: value}), + {name: "TypeError", message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'excludedAttributes' property ${label}`); + } + }); + + it("should expect 'attributes' property of 'request' params to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({attributes: ["test"]}), + "Instance method 'prepare' rejected valid 'attributes' property non-empty string array value"); + + for (let [label, value] of suites.arrays) { + assert.throws(() => new SearchRequest().prepare({attributes: value}), + {name: "TypeError", message: "Expected 'attributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'attributes' property ${label}`); + } + }); + + it("should expect 'sortBy' property of 'params' argument to be a non-empty string, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({sortBy: "test"}), + "Instance method 'prepare' rejected valid 'sortBy' property string value 'test'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({sortBy: value}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'sortBy' property ${label}`); + } + }); + + it("should expect 'sortOrder' property of 'params' argument to be either 'ascending' or 'descending', if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({sortOrder: "ascending"}), + "Instance method 'prepare' rejected valid 'sortOrder' property string value 'ascending'"); + + for (let [label, value] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({sortOrder: value}), + {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'sortOrder' property ${label}`); + } + }); + + it("should expect 'startIndex' property of 'params' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({startIndex: 1}), + "Instance method 'prepare' rejected valid 'startIndex' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SearchRequest().prepare({startIndex: value}), + {name: "TypeError", message: "Expected 'startIndex' parameter to be a positive integer in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'startIndex' property ${label}`); + } + }); + + it("should expect 'count' property of 'params' argument to be a positive integer, if specified", () => { + assert.doesNotThrow(() => new SearchRequest().prepare({count: 1}), + "Instance method 'prepare' rejected valid 'count' property positive integer value '1'"); + + for (let [label, value] of suites.numbers) { + assert.throws(() => new SearchRequest().prepare({count: value}), + {name: "TypeError", message: "Expected 'count' parameter to be a positive integer in 'prepare' method of SearchRequest"}, + `Instance method 'prepare' did not reject invalid 'count' property ${label}`); + } + }); + }); + + describe("#apply()", () => { + it("should have instance method 'apply'", () => { + assert.ok(typeof (new SearchRequest()).apply === "function", + "Instance method 'apply' not defined"); + }); + + it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { + await assert.rejects(() => new SearchRequest().apply([{}]), + {name: "TypeError", message: "Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of SearchRequest"}, + "Instance method 'apply' did not expect 'resourceTypes' parameter to be an array of Resource type classes"); + }); + + it("should return a ListResponse message instance", async () => { + assert.ok(await (new SearchRequest()).apply() instanceof SCIMMY.Messages.ListResponse, + "Instance method 'apply' did not return an instance of ListResponse"); }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/resources.js b/test/lib/resources.js index 35827e7..77d3f01 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -1,49 +1,197 @@ import assert from "assert"; +import sinon from "sinon"; +import * as Schemas from "#@/lib/schemas.js"; import SCIMMY from "#@/scimmy.js"; -import {SchemaSuite} from "./resources/schema.js"; -import {ResourceTypeSuite} from "./resources/resourcetype.js"; -import {ServiceProviderConfigSuite} from "./resources/spconfig.js"; -import {UserSuite} from "./resources/user.js"; -import {GroupSuite} from "./resources/group.js"; +import Resources from "#@/lib/resources.js"; + +describe("SCIMMY.Resources", () => { + const sandbox = sinon.createSandbox(); + + it("should include static class 'Schema'", () => { + assert.ok(!!Resources.Schema, + "Static class 'Schema' not defined"); + }); + + it("should include static class 'ResourceType'", () => { + assert.ok(!!Resources.ResourceType, + "Static class 'ResourceType' not defined"); + }); + + it("should include static class 'ServiceProviderConfig'", () => { + assert.ok(!!Resources.ServiceProviderConfig, + "Static class 'ServiceProviderConfig' not defined"); + }); + + it("should include static class 'User'", () => { + assert.ok(!!Resources.User, + "Static class 'User' not defined"); + }); + + it("should include static class 'Group'", () => { + assert.ok(!!Resources.Group, + "Static class 'Group' not defined"); + }); + + after(() => sandbox.restore()); + before(() => sandbox.stub(Schemas.default, "declare")); + + describe(".declare()", () => { + it("should be implemented", () => { + assert.ok(typeof Resources.declare === "function", + "Static method 'declare' not defined"); + }); + + it("should expect 'resource' argument to be an instance of Resource", () => { + assert.throws(() => Resources.declare(), + {name: "TypeError", message: "Registering resource must be of type 'Resource'"}, + "Static method 'declare' did not expect 'resource' parameter to be specified"); + assert.throws(() => Resources.declare({}), + {name: "TypeError", message: "Registering resource must be of type 'Resource'"}, + "Static method 'declare' did not expect 'resource' parameter to be an instance of Resource"); + }); + + it("should expect 'config' argument to be either a string or an object", () => { + assert.throws(() => Resources.declare(Resources.User, false), + {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, + "Static method 'declare' did not fail with 'config' parameter boolean value 'false'"); + assert.throws(() => Resources.declare(Resources.User, []), + {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, + "Static method 'declare' did not fail with 'config' parameter array value"); + assert.throws(() => Resources.declare(Resources.User, 1), + {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, + "Static method 'declare' did not fail with 'config' parameter number value '1'"); + }); + + it("should refuse to declare internal resource implementations 'Schema', 'ResourceType', and 'ServiceProviderConfig'", () => { + assert.throws(() => Resources.declare(Resources.Schema), + {name: "TypeError", message: "Refusing to declare internal resource implementation 'Schema'"}, + "Static method 'declare' did not refuse to declare internal resource implementation 'Schema'"); + assert.throws(() => Resources.declare(Resources.ResourceType), + {name: "TypeError", message: "Refusing to declare internal resource implementation 'ResourceType'"}, + "Static method 'declare' did not refuse to declare internal resource implementation 'ResourceType'"); + assert.throws(() => Resources.declare(Resources.ServiceProviderConfig), + {name: "TypeError", message: "Refusing to declare internal resource implementation 'ServiceProviderConfig'"}, + "Static method 'declare' did not refuse to declare internal resource implementation 'ServiceProviderConfig'"); + }); + + it("should return self after declaration if 'config' argument was an object", () => { + assert.strictEqual(Resources.declare(Resources.User, {}), Resources, + "Static method 'declare' did not return Resources for chaining"); + }); + + it("should return resource after declaration if 'config' argument was not an object", () => { + assert.strictEqual(Resources.declare(Resources.Group), Resources.Group, + "Static method 'declare' did not return declared resource for chaining"); + }); + + it("should expect all resources to have unique names", () => { + assert.throws(() => Resources.declare(Resources.Group, "User"), + {name: "TypeError", message: "Resource 'User' already declared"}, + "Static method 'declare' did not expect resources to have unique names"); + }); + + it("should not declare an existing resource under a new name", () => { + assert.throws(() => Resources.declare(Resources.Group, "Test"), + {name: "TypeError", message: `Resource 'Test' already declared with name 'Group'`}, + "Static method 'declare' did not prevent existing resource from declaring under a new name"); + }); + + it("should declare resource type implementation's schema definition to Schemas", () => { + for (let resource of [Resources.User, Resources.Group]) { + assert.ok(SCIMMY.Schemas.declare.calledWith(resource.schema.definition), + "Static method 'declare' did not declare resource type implementation's schema definition"); + } + }); + }); + + describe(".declared()", () => { + it("should be implemented", () => { + assert.ok(typeof Resources.declared === "function", + "Static method 'declared' not defined"); + }); + + it("should return all declared resources when called without arguments", () => { + assert.deepStrictEqual(Resources.declared(), {User: Resources.User, Group: Resources.Group}, + "Static method 'declared' did not return all declared resources when called without arguments"); + }); + + it("should find declared resource by name when 'config' argument is a string", () => { + assert.deepStrictEqual(Resources.declared("User"), Resources.User, + "Static method 'declared' did not find declared resource 'User' when called with 'config' string value 'User'"); + }); + + it("should find declaration status of resource when 'config' argument is a resource instance", () => { + assert.ok(Resources.declared(Resources.User), + "Static method 'declared' did not find declaration status of declared 'User' resource by instance"); + assert.ok(!Resources.declared(Resources.ResourceType), + "Static method 'declared' did not find declaration status of undeclared 'ResourceType' resource by instance"); + }); + }); +}); export const ResourcesHooks = { endpoint: (TargetResource) => (() => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), - "Resource did not implement static member 'endpoint'"); - assert.ok(typeof TargetResource.endpoint === "string", - "Static member 'endpoint' was not a string"); + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), + "Resource did not implement static member 'endpoint'"); + }); + + it("should be a string", () => { + assert.ok(typeof TargetResource.endpoint === "string", + "Static member 'endpoint' was not a string"); + }); }), schema: (TargetResource, implemented = true) => (() => { if (implemented) { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Resource did not implement static member 'schema'"); - assert.ok(TargetResource.schema.prototype instanceof SCIMMY.Types.Schema, - "Static member 'schema' was not a Schema"); + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Resource did not implement static member 'schema'"); + }); + + it("should be an instance of Schema", () => { + assert.ok(TargetResource.schema.prototype instanceof SCIMMY.Types.Schema, + "Static member 'schema' was not a Schema"); + }); } else { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Static member 'schema' unexpectedly implemented by resource"); + it("should not be implemented", () => { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Static member 'schema' unexpectedly implemented by resource"); + }); } }), extend: (TargetResource, overrides = false) => (() => { if (!overrides) { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Static method 'extend' unexpectedly overridden by resource"); + it("should not be overridden", () => { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Static method 'extend' unexpectedly overridden by resource"); + }); } else { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Resource did not override static method 'extend'"); - assert.ok(typeof TargetResource.extend === "function", - "Static method 'extend' was not a function"); - assert.throws(() => TargetResource.extend(), - {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, - "Static method 'extend' did not throw failure"); + it("should be overridden", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Resource did not override static method 'extend'"); + assert.ok(typeof TargetResource.extend === "function", + "Static method 'extend' was not a function"); + }); + + it("should throw an 'unsupported' error", () => { + assert.throws(() => TargetResource.extend(), + {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, + "Static method 'extend' did not throw failure"); + }); } }), ingress: (TargetResource, fixtures) => (() => { - if (fixtures) { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.ingress(), + {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'ingress' unexpectedly implemented by resource"); + }); + } else { const handler = async (res, instance) => { const {egress} = await fixtures; const target = Object.assign( - (!!res.id ? egress.find(f => f.id === res.id) : {id: "5"}), + (!!res.id ? egress.find(f => f.id === res.id) : {id: "5"}), JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) ); @@ -51,20 +199,27 @@ export const ResourcesHooks = { return target; }; - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), - "Resource did not implement static method 'ingress'"); - assert.ok(typeof TargetResource.ingress === "function", - "Static method 'ingress' was not a function"); - assert.strictEqual(TargetResource.ingress(handler), TargetResource, - "Static method 'ingress' did not correctly set ingress handler"); - } else { - assert.throws(() => TargetResource.ingress(), - {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'ingress' unexpectedly implemented by resource"); + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), + "Resource did not implement static method 'ingress'"); + assert.ok(typeof TargetResource.ingress === "function", + "Static method 'ingress' was not a function"); + }); + + it("should set private ingress handler", () => { + assert.strictEqual(TargetResource.ingress(handler), TargetResource, + "Static method 'ingress' did not correctly set ingress handler"); + }); } }), egress: (TargetResource, fixtures) => (() => { - if (fixtures) { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.egress(), + {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'egress' unexpectedly implemented by resource"); + }); + } else { const handler = async (res) => { const {egress} = await fixtures; const target = (!!res.id ? egress.find(f => f.id === res.id) : egress); @@ -73,20 +228,27 @@ export const ResourcesHooks = { else return (Array.isArray(target) ? target : [target]); }; - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), - "Resource did not implement static method 'egress'"); - assert.ok(typeof TargetResource.egress === "function", - "Static method 'egress' was not a function"); - assert.strictEqual(TargetResource.egress(handler), TargetResource, - "Static method 'egress' did not correctly set egress handler"); - } else { - assert.throws(() => TargetResource.egress(), - {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'egress' unexpectedly implemented by resource"); + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), + "Resource did not implement static method 'egress'"); + assert.ok(typeof TargetResource.egress === "function", + "Static method 'egress' was not a function"); + }); + + it("should set private egress handler", () => { + assert.strictEqual(TargetResource.egress(handler), TargetResource, + "Static method 'egress' did not correctly set egress handler"); + }); } }), degress: (TargetResource, fixtures) => (() => { - if (fixtures) { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.degress(), + {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'degress' unexpectedly implemented by resource"); + }); + } else { const handler = async (res) => { const {egress} = await fixtures; const index = egress.indexOf(egress.find(f => f.id === res.id)); @@ -95,27 +257,28 @@ export const ResourcesHooks = { else egress.splice(index, 1); }; - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), - "Resource did not implement static method 'degress'"); - assert.ok(typeof TargetResource.degress === "function", - "Static method 'degress' was not a function"); - assert.strictEqual(TargetResource.degress(handler), TargetResource, - "Static method 'degress' did not correctly set degress handler"); - } else { - assert.throws(() => TargetResource.degress(), - {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'degress' unexpectedly implemented by resource"); + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), + "Resource did not implement static method 'degress'"); + assert.ok(typeof TargetResource.degress === "function", + "Static method 'degress' was not a function"); + }); + + it("should set private degress handler", () => { + assert.strictEqual(TargetResource.degress(handler), TargetResource, + "Static method 'degress' did not correctly set degress handler"); + }); } }), basepath: (TargetResource) => (() => { - it("should implement static method 'basepath'", () => { + it("should be implemented", () => { assert.ok(Object.getOwnPropertyNames(TargetResource).includes("basepath"), "Static method 'basepath' was not implemented by resource"); assert.ok(typeof TargetResource.basepath === "function", "Static method 'basepath' was not a function"); }); - it("should only set basepath once, and do nothing if basepath has already been set", () => { + it("should only set basepath once, then do nothing", () => { const existing = TargetResource.basepath(); const expected = `/scim${TargetResource.endpoint}`; @@ -199,58 +362,72 @@ export const ResourcesHooks = { } }), read: (TargetResource, fixtures, listable = true) => (() => { - it("should implement instance method 'read'", () => { - assert.ok("read" in (new TargetResource()), - "Resource did not implement instance method 'read'"); - assert.ok(typeof (new TargetResource()).read === "function", - "Instance method 'read' was not a function"); - }); - - if (listable) { - it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { - const {egress: expected} = await fixtures; - const result = await (new TargetResource()).read(); - const resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ - ...r, schemas: undefined, meta: undefined, attributes: undefined - }))); - - assert.ok(result instanceof SCIMMY.Messages.ListResponse, - "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); - assert.deepStrictEqual(resources, expected, - "Instance method 'read' did not return a ListResponse containing all resources from fixture"); + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().read(), + {name: "TypeError", message: `Method 'read' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'read' unexpectedly implemented by resource"); }); - - it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { - const {egress: [expected]} = await fixtures; - const actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource(expected.id)).read(), - schemas: undefined, meta: undefined, attributes: undefined - })); - - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource instance by ID"); + } else { + it("should be implemented", () => { + assert.ok("read" in (new TargetResource()), + "Resource did not implement instance method 'read'"); + assert.ok(typeof (new TargetResource()).read === "function", + "Instance method 'read' was not a function"); }); - it("should expect a resource with supplied ID to exist", async () => { - await assert.rejects(() => new TargetResource("10").read(), - {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, - "Instance method 'read' did not expect requested resource to exist"); - }); - } else { - it("should return the requested resource without sugar-coating", async () => { - const {egress: expected} = await fixtures; - const actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource()).read(), schemas: undefined, meta: undefined - })); + if (listable) { + it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { + const {egress: expected} = await fixtures; + const result = await (new TargetResource()).read(); + const resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ + ...r, schemas: undefined, meta: undefined, attributes: undefined + }))); + + assert.ok(result instanceof SCIMMY.Messages.ListResponse, + "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); + assert.deepStrictEqual(resources, expected, + "Instance method 'read' did not return a ListResponse containing all resources from fixture"); + }); - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource without sugar-coating"); - }); + it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { + const {egress: [expected]} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource(expected.id)).read(), + schemas: undefined, meta: undefined, attributes: undefined + })); + + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource instance by ID"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + await assert.rejects(() => new TargetResource("10").read(), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'read' did not expect requested resource to exist"); + }); + } else { + it("should return the requested resource without sugar-coating", async () => { + const {egress: expected} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource()).read(), schemas: undefined, meta: undefined + })); + + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource without sugar-coating"); + }); + } } }), write: (TargetResource, fixtures) => (() => { - if (fixtures) { - it("should implement instance method 'write'", () => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().write(), + {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'write' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { assert.ok("write" in (new TargetResource()), "Resource did not implement instance method 'write'"); assert.ok(typeof (new TargetResource()).write === "function", @@ -286,7 +463,7 @@ export const ResourcesHooks = { } } }); - + it("should call ingress to create new resources when resource instantiated without ID", async () => { const {ingress: source} = await fixtures; const result = await (new TargetResource()).write(source); @@ -305,14 +482,16 @@ export const ResourcesHooks = { assert.deepStrictEqual(actual, expected, "Instance method 'write' did not update existing resource"); }); - } else { - assert.throws(() => new TargetResource().write(), - {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'write' unexpectedly implemented by resource"); } }), patch: (TargetResource, fixtures) => (() => { - if (fixtures) { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().patch(), + {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'patch' unexpectedly implemented by resource"); + }); + } else { it("should implement instance method 'patch'", () => { assert.ok("patch" in (new TargetResource()), "Resource did not implement instance method 'patch'"); @@ -328,7 +507,7 @@ export const ResourcesHooks = { ]; await assert.rejects(() => new TargetResource().patch(), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", + {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: "Missing message body from PatchOp request"}, "Instance method 'patch' did not expect 'message' parameter to exist"); @@ -364,15 +543,17 @@ export const ResourcesHooks = { assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, "Instance method 'patch' did not return the full resource when resource was modified"); }); - } else { - assert.throws(() => new TargetResource().patch(), - {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'patch' unexpectedly implemented by resource"); } }), dispose: (TargetResource, fixtures) => (() => { - if (fixtures) { - it("should implement instance method 'dispose'", () => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().dispose(), + {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'dispose' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { assert.ok("dispose" in (new TargetResource()), "Resource did not implement instance method 'dispose'"); assert.ok(typeof (new TargetResource()).dispose === "function", @@ -399,116 +580,6 @@ export const ResourcesHooks = { {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, "Instance method 'dispose' did not expect requested resource to exist"); }); - } else { - assert.throws(() => new TargetResource().dispose(), - {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'dispose' unexpectedly implemented by resource"); } }) -}; - -export const ResourcesSuite = () => { - it("should include static class 'Resources'", () => - assert.ok(!!SCIMMY.Resources, "Static class 'Resources' not defined")); - - describe("SCIMMY.Resources", () => { - describe(".declare()", () => { - it("should have static method 'declare'", () => { - assert.ok(typeof SCIMMY.Resources.declare === "function", - "Static method 'declare' not defined"); - }); - - it("should expect 'resource' argument to be an instance of Resource", () => { - assert.throws(() => SCIMMY.Resources.declare(), - {name: "TypeError", message: "Registering resource must be of type 'Resource'"}, - "Static method 'declare' did not expect 'resource' parameter to be specified"); - assert.throws(() => SCIMMY.Resources.declare({}), - {name: "TypeError", message: "Registering resource must be of type 'Resource'"}, - "Static method 'declare' did not expect 'resource' parameter to be an instance of Resource"); - }); - - it("should expect 'config' argument to be either a string or an object", () => { - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.User, false), - {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, - "Static method 'declare' did not fail with 'config' parameter boolean value 'false'"); - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.User, []), - {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, - "Static method 'declare' did not fail with 'config' parameter array value"); - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.User, 1), - {name: "TypeError", message: "Resource declaration expected 'config' parameter to be either a name string or configuration object"}, - "Static method 'declare' did not fail with 'config' parameter number value '1'"); - }); - - it("should refuse to declare internal resource implementations 'Schema', 'ResourceType', and 'ServiceProviderConfig'", () => { - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.Schema), - {name: "TypeError", message: "Refusing to declare internal resource implementation 'Schema'"}, - "Static method 'declare' did not refuse to declare internal resource implementation 'Schema'"); - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.ResourceType), - {name: "TypeError", message: "Refusing to declare internal resource implementation 'ResourceType'"}, - "Static method 'declare' did not refuse to declare internal resource implementation 'ResourceType'"); - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.ServiceProviderConfig), - {name: "TypeError", message: "Refusing to declare internal resource implementation 'ServiceProviderConfig'"}, - "Static method 'declare' did not refuse to declare internal resource implementation 'ServiceProviderConfig'"); - }); - - it("should return self after declaration if 'config' argument was an object", () => { - assert.strictEqual(SCIMMY.Resources.declare(SCIMMY.Resources.User, {}), SCIMMY.Resources, - "Static method 'declare' did not return Resources for chaining"); - }); - - it("should return resource after declaration if 'config' argument was not an object", () => { - assert.strictEqual(SCIMMY.Resources.declare(SCIMMY.Resources.Group), SCIMMY.Resources.Group, - "Static method 'declare' did not return declared resource for chaining"); - }); - - it("should expect all resources to have unique names", () => { - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.Group, "User"), - {name: "TypeError", message: "Resource 'User' already declared"}, - "Static method 'declare' did not expect resources to have unique names"); - }); - - it("should not declare an existing resource under a new name", () => { - assert.throws(() => SCIMMY.Resources.declare(SCIMMY.Resources.Group, "Test"), - {name: "TypeError", message: `Resource 'Test' already declared with name 'Group'`}, - "Static method 'declare' did not prevent existing resource from declaring under a new name"); - }); - - it("should declare resource type implementation's schema definition to SCIMMY.Schemas", () => { - for (let schema of Object.values(SCIMMY.Resources.declared()).map(r => r.schema.definition)) { - assert.ok(SCIMMY.Schemas.declared(schema), - "Static method 'declare' did not declare resource type implementation's schema definition"); - } - }); - }); - - describe(".declared()", () => { - it("should have static method 'declared'", () => { - assert.ok(typeof SCIMMY.Resources.declared === "function", - "Static method 'declared' not defined"); - }); - - it("should return all declared resources when called without arguments", () => { - assert.deepStrictEqual(SCIMMY.Resources.declared(), {User: SCIMMY.Resources.User, Group: SCIMMY.Resources.Group}, - "Static method 'declared' did not return all declared resources when called without arguments"); - }); - - it("should find declared resource by name when 'config' argument is a string", () => { - assert.deepStrictEqual(SCIMMY.Resources.declared("User"), SCIMMY.Resources.User, - "Static method 'declared' did not find declared resource 'User' when called with 'config' string value 'User'"); - }); - - it("should find declaration status of resource when 'config' argument is a resource instance", () => { - assert.ok(SCIMMY.Resources.declared(SCIMMY.Resources.User), - "Static method 'declared' did not find declaration status of declared 'User' resource by instance"); - assert.ok(!SCIMMY.Resources.declared(SCIMMY.Resources.ResourceType), - "Static method 'declared' did not find declaration status of undeclared 'ResourceType' resource by instance"); - }); - }); - - SchemaSuite(); - ResourceTypeSuite(); - ServiceProviderConfigSuite(); - UserSuite(); - GroupSuite(); - }); }; \ No newline at end of file diff --git a/test/lib/resources/group.js b/test/lib/resources/group.js index db39ec6..f1da261 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -1,30 +1,23 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Group} from "#@/lib/resources/group.js"; import {ResourcesHooks} from "../resources.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); -export const GroupSuite = () => { - it("should include static class 'Group'", () => - assert.ok(!!SCIMMY.Resources.Group, "Static class 'Group' not defined")); - - describe("SCIMMY.Resources.Group", () => { - it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.Group)); - it("should implement static member 'schema' that is a Schema", ResourcesHooks.schema(SCIMMY.Resources.Group)); - it("should not override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.Group, false)); - it("should implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.Group, fixtures)); - it("should implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.Group, fixtures)); - it("should implement static method 'degress'", ResourcesHooks.degress(SCIMMY.Resources.Group, fixtures)); - - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.Group)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.Group)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.Group, fixtures)); - describe("#write()", ResourcesHooks.write(SCIMMY.Resources.Group, fixtures)); - describe("#patch()", ResourcesHooks.patch(SCIMMY.Resources.Group, fixtures)); - describe("#dispose()", ResourcesHooks.dispose(SCIMMY.Resources.Group, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Resources.Group", () => { + context(".endpoint", ResourcesHooks.endpoint(Group)); + context(".schema", ResourcesHooks.schema(Group)); + context(".basepath()", ResourcesHooks.basepath(Group)); + context(".extend()", ResourcesHooks.extend(Group, false)); + context(".ingress()", ResourcesHooks.ingress(Group, fixtures)); + context(".egress()", ResourcesHooks.egress(Group, fixtures)); + context(".degress()", ResourcesHooks.degress(Group, fixtures)); + context("@constructor", ResourcesHooks.construct(Group)); + context("#read()", ResourcesHooks.read(Group, fixtures)); + context("#write()", ResourcesHooks.write(Group, fixtures)); + context("#patch()", ResourcesHooks.patch(Group, fixtures)); + context("#dispose()", ResourcesHooks.dispose(Group, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/resources/resourcetype.js b/test/lib/resources/resourcetype.js index 94dfc30..39c0529 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -1,30 +1,37 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import sinon from "sinon"; +import * as Resources from "#@/lib/resources.js"; +import {User} from "#@/lib/resources/user.js"; +import {Group} from "#@/lib/resources/group.js"; +import {ResourceType} from "#@/lib/resources/resourcetype.js"; import {ResourcesHooks} from "../resources.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); -export const ResourceTypeSuite = () => { - it("should include static class 'ResourceType'", () => - assert.ok(!!SCIMMY.Resources.ResourceType, "Static class 'ResourceType' not defined")); +describe("SCIMMY.Resources.ResourceType", () => { + const sandbox = sinon.createSandbox(); - describe("SCIMMY.Resources.ResourceType", () => { - it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.ResourceType)); - it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.ResourceType, false)); - it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.ResourceType, true)); - it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.ResourceType, false)); - it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.ResourceType, false)); - it("should not implement static method 'degress'", ResourcesHooks.degress(SCIMMY.Resources.ResourceType, false)); - it("should not implement instance method 'write'", ResourcesHooks.write(SCIMMY.Resources.ResourceType, false)); - it("should not implement instance method 'patch'", ResourcesHooks.patch(SCIMMY.Resources.ResourceType, false)); - it("should not implement instance method 'dispose'", ResourcesHooks.dispose(SCIMMY.Resources.ResourceType, false)); + after(() => sandbox.restore()); + before(() => { + const declared = sandbox.stub(Resources.default, "declared"); - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.ResourceType)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.ResourceType, false)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.ResourceType, fixtures)); + declared.returns([User, Group]); + declared.withArgs(User.schema.definition.name).returns(User); }); -}; \ No newline at end of file + + context(".endpoint", ResourcesHooks.endpoint(ResourceType)); + context(".schema", ResourcesHooks.schema(ResourceType, false)); + context(".basepath()", ResourcesHooks.basepath(ResourceType)); + context(".extend()", ResourcesHooks.extend(ResourceType, true)); + context(".ingress()", ResourcesHooks.ingress(ResourceType, false)); + context(".egress()", ResourcesHooks.egress(ResourceType, false)); + context(".degress()", ResourcesHooks.degress(ResourceType, false)); + context("@constructor", ResourcesHooks.construct(ResourceType, false)); + context("#read()", ResourcesHooks.read(ResourceType, fixtures)); + context("#write()", ResourcesHooks.write(ResourceType, false)); + context("#patch()", ResourcesHooks.patch(ResourceType, false)); + context("#dispose()", ResourcesHooks.dispose(ResourceType, false)); +}); \ No newline at end of file diff --git a/test/lib/resources/schema.js b/test/lib/resources/schema.js index f4b80ca..89592bb 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -1,30 +1,37 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import sinon from "sinon"; +import * as Schemas from "#@/lib/schemas.js"; +import {User} from "#@/lib/schemas/user.js"; +import {Group} from "#@/lib/schemas/group.js"; +import {Schema} from "#@/lib/resources/schema.js"; import {ResourcesHooks} from "../resources.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); -export const SchemaSuite = () => { - it("should include static class 'Schema'", () => - assert.ok(!!SCIMMY.Resources.Schema, "Static class 'Schema' not defined")); +describe("SCIMMY.Resources.Schema", () => { + const sandbox = sinon.createSandbox(); - describe("SCIMMY.Resources.Schema", () => { - it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.Schema)); - it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.Schema, false)); - it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.Schema, true)); - it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.Schema, false)); - it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.Schema, false)); - it("should not implement static method 'degress'", ResourcesHooks.degress(SCIMMY.Resources.Schema, false)); - it("should not implement instance method 'write'", ResourcesHooks.write(SCIMMY.Resources.Schema, false)); - it("should not implement instance method 'patch'", ResourcesHooks.patch(SCIMMY.Resources.Schema, false)); - it("should not implement instance method 'dispose'", ResourcesHooks.dispose(SCIMMY.Resources.Schema, false)); + after(() => sandbox.restore()); + before(() => { + const declared = sandbox.stub(Schemas.default, "declared"); - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.Schema)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.Schema, false)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.Schema, fixtures)); + declared.returns([User.definition, Group.definition]); + declared.withArgs(User.definition.id).returns(User.definition); }); -}; \ No newline at end of file + + context(".endpoint", ResourcesHooks.endpoint(Schema)); + context(".schema", ResourcesHooks.schema(Schema, false)); + context(".basepath()", ResourcesHooks.basepath(Schema)); + context(".extend()", ResourcesHooks.extend(Schema, true)); + context(".ingress()", ResourcesHooks.ingress(Schema, false)); + context(".egress()", ResourcesHooks.egress(Schema, false)); + context(".degress()", ResourcesHooks.degress(Schema, false)); + context("@constructor", ResourcesHooks.construct(Schema, false)); + context("#read()", ResourcesHooks.read(Schema, fixtures)); + context("#write()", ResourcesHooks.write(Schema, false)); + context("#patch()", ResourcesHooks.patch(Schema, false)); + context("#dispose()", ResourcesHooks.dispose(Schema, false)); +}); \ No newline at end of file diff --git a/test/lib/resources/spconfig.js b/test/lib/resources/spconfig.js index c8a8d1a..7798b5c 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -1,30 +1,34 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import sinon from "sinon"; +import * as Config from "#@/lib/config.js"; +import {ServiceProviderConfig} from "#@/lib/resources/spconfig.js"; import {ResourcesHooks} from "../resources.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); -export const ServiceProviderConfigSuite = () => { - it("should include static class 'ServiceProviderConfig'", () => - assert.ok(!!SCIMMY.Resources.ServiceProviderConfig, "Static class 'ServiceProviderConfig' not defined")); +describe("SCIMMY.Resources.ServiceProviderConfig", () => { + const sandbox = sinon.createSandbox(); - describe("SCIMMY.Resources.ServiceProviderConfig", () => { - it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.ServiceProviderConfig)); - it("should not implement static member 'schema'", ResourcesHooks.schema(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.ServiceProviderConfig, true)); - it("should not implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement static method 'degress'", ResourcesHooks.degress(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement instance method 'write'", ResourcesHooks.write(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement instance method 'patch'", ResourcesHooks.patch(SCIMMY.Resources.ServiceProviderConfig, false)); - it("should not implement instance method 'dispose'", ResourcesHooks.dispose(SCIMMY.Resources.ServiceProviderConfig, false)); - - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.ServiceProviderConfig)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.ServiceProviderConfig, false)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.ServiceProviderConfig, fixtures, false)); - }); -}; \ No newline at end of file + after(() => sandbox.restore()); + before(() => sandbox.stub(Config.default, "get").returns({ + documentationUri: undefined, authenticationSchemes: [], filter: {supported: false, maxResults: 200}, + sort: {supported: false}, bulk: {supported: false, maxOperations: 1000, maxPayloadSize: 1048576}, + patch: {supported: false}, changePassword: {supported: false}, etag: {supported: false} + })); + + context(".endpoint", ResourcesHooks.endpoint(ServiceProviderConfig)); + context(".schema", ResourcesHooks.schema(ServiceProviderConfig, false)); + context(".basepath()", ResourcesHooks.basepath(ServiceProviderConfig)); + context(".extend()", ResourcesHooks.extend(ServiceProviderConfig, true)); + context(".ingress()", ResourcesHooks.ingress(ServiceProviderConfig, false)); + context(".egress()", ResourcesHooks.egress(ServiceProviderConfig, false)); + context(".degress()", ResourcesHooks.degress(ServiceProviderConfig, false)); + context("@constructor", ResourcesHooks.construct(ServiceProviderConfig, false)); + context("#read()", ResourcesHooks.read(ServiceProviderConfig, fixtures, false)); + context("#write()", ResourcesHooks.write(ServiceProviderConfig, false)); + context("#patch()", ResourcesHooks.patch(ServiceProviderConfig, false)); + context("#dispose()", ResourcesHooks.dispose(ServiceProviderConfig, false)); +}); \ No newline at end of file diff --git a/test/lib/resources/user.js b/test/lib/resources/user.js index da80823..c32b1da 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -1,30 +1,23 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {User} from "#@/lib/resources/user.js"; import {ResourcesHooks} from "../resources.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); -export const UserSuite = () => { - it("should include static class 'User'", () => - assert.ok(!!SCIMMY.Resources.User, "Static class 'User' not defined")); - - describe("SCIMMY.Resources.User", () => { - it("should implement static member 'endpoint' that is a string", ResourcesHooks.endpoint(SCIMMY.Resources.User)); - it("should implement static member 'schema' that is a Schema", ResourcesHooks.schema(SCIMMY.Resources.User)); - it("should not override static method 'extend'", ResourcesHooks.extend(SCIMMY.Resources.User, false)); - it("should implement static method 'ingress'", ResourcesHooks.ingress(SCIMMY.Resources.User, fixtures)); - it("should implement static method 'egress'", ResourcesHooks.egress(SCIMMY.Resources.User, fixtures)); - it("should implement static method 'degress'", ResourcesHooks.degress(SCIMMY.Resources.User, fixtures)); - - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.User)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.User)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.User, fixtures)); - describe("#write()", ResourcesHooks.write(SCIMMY.Resources.User, fixtures)); - describe("#patch()", ResourcesHooks.patch(SCIMMY.Resources.User, fixtures)); - describe("#dispose()", ResourcesHooks.dispose(SCIMMY.Resources.User, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Resources.User", () => { + context(".endpoint", ResourcesHooks.endpoint(User)); + context(".schema", ResourcesHooks.schema(User)); + context(".basepath()", ResourcesHooks.basepath(User)); + context(".extend()", ResourcesHooks.extend(User, false)); + context(".ingress()", ResourcesHooks.ingress(User, fixtures)); + context(".egress()", ResourcesHooks.egress(User, fixtures)); + context(".degress()", ResourcesHooks.degress(User, fixtures)); + context("@constructor", ResourcesHooks.construct(User)); + context("#read()", ResourcesHooks.read(User, fixtures)); + context("#write()", ResourcesHooks.write(User, fixtures)); + context("#patch()", ResourcesHooks.patch(User, fixtures)); + context("#dispose()", ResourcesHooks.dispose(User, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/schemas.js b/test/lib/schemas.js index aa5af1f..371b168 100644 --- a/test/lib/schemas.js +++ b/test/lib/schemas.js @@ -1,10 +1,121 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; -import {ResourceTypeSuite} from "./schemas/resourcetype.js"; -import {ServiceProviderConfigSuite} from "./schemas/spconfig.js"; -import {UserSuite} from "./schemas/user.js"; -import {GroupSuite} from "./schemas/group.js"; -import {EnterpriseUserSuite} from "./schemas/enterpriseuser.js"; +import Schemas from "#@/lib/schemas.js"; + +describe("SCIMMY.Schemas", () => { + it("should include static class 'ResourceType'", () => { + assert.ok(!!Schemas.ResourceType, + "Static class 'ResourceType' not defined"); + }); + + it("should include static class 'ServiceProviderConfig'", () => { + assert.ok(!!Schemas.ServiceProviderConfig, + "Static class 'ServiceProviderConfig' not defined"); + }); + + it("should include static class 'User'", () => { + assert.ok(!!Schemas.User, + "Static class 'User' not defined"); + }); + + it("should include static class 'Group'", () => { + assert.ok(!!Schemas.Group, + "Static class 'Group' not defined"); + }); + + it("should include static class 'EnterpriseUser'", () => { + assert.ok(!!Schemas.EnterpriseUser, + "Static class 'EnterpriseUser' not defined"); + }); + + describe(".declare()", () => { + it("should be implemented", () => { + assert.ok(typeof Schemas.declare === "function", + "Static method 'declare' not defined"); + }); + + it("should expect 'definition' argument to be an instance of SchemaDefinition", () => { + assert.throws(() => Schemas.declare(), + {name: "TypeError", message: "Registering schema definition must be of type 'SchemaDefinition'"}, + "Static method 'declare' did not expect 'definition' parameter to be specified"); + assert.throws(() => Schemas.declare({}), + {name: "TypeError", message: "Registering schema definition must be of type 'SchemaDefinition'"}, + "Static method 'declare' did not expect 'definition' parameter to be an instance of SchemaDefinition"); + }); + + it("should always return self after declaration", () => { + assert.strictEqual(Schemas.declare(Schemas.User.definition), Schemas, + "Static method 'declare' did not return Schemas for chaining"); + }); + + it("should ignore definition instances that are already declared with the same name", () => { + assert.doesNotThrow(() => Schemas.declare(Schemas.User.definition), + "Static method 'declare' did not ignore redeclaration of existing name/instance pair"); + }); + + it("should expect all schema definitions to have unique names", () => { + assert.throws(() => Schemas.declare(Schemas.EnterpriseUser.definition, "User"), + {name: "TypeError", message: `Schema definition 'User' already declared with id '${Schemas.User.definition.id}'`}, + "Static method 'declare' did not expect schema definitions to have unique names"); + }); + + it("should not declare an existing schema definition under a new name", () => { + assert.throws(() => Schemas.declare(Schemas.User.definition, "Test"), + {name: "TypeError", message: `Schema definition '${Schemas.User.definition.id}' already declared with name 'User'`}, + "Static method 'declare' did not prevent existing schema definition from declaring under a new name"); + }); + }); + + describe(".declared()", () => { + it("should be implemented", () => { + assert.ok(typeof Schemas.declared === "function", + "Static method 'declared' not defined"); + }); + + it("should return all declared definitions when called without arguments", () => { + assert.deepStrictEqual(Schemas.declared(), [Schemas.User.definition], + "Static method 'declared' did not return all declared definitions when called without arguments"); + }); + + it("should return boolean 'false' when called with unexpected arguments", () => { + assert.strictEqual(Schemas.declared({}), false, + "Static method 'declared' did not return boolean 'false' when called with unexpected arguments"); + }); + + it("should find declaration status of definitions by name", () => { + assert.ok(Schemas.declared("User"), + "Static method 'declared' did not find declaration status of declared 'User' schema by name"); + assert.ok(!Schemas.declared("EnterpriseUser"), + "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by name"); + }); + + it("should find declaration status of definitions by ID", () => { + assert.ok(Schemas.declared(Schemas.User.definition.id), + "Static method 'declared' did not find declaration status of declared 'User' schema by ID"); + assert.ok(!Schemas.declared(Schemas.EnterpriseUser.definition.id), + "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by ID"); + }); + + it("should find declaration status of definitions by instance", () => { + assert.ok(Schemas.declared(Schemas.User.definition), + "Static method 'declared' did not find declaration status of declared 'User' schema by instance"); + assert.ok(!Schemas.declared(Schemas.EnterpriseUser.definition), + "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by instance"); + }); + + it("should find nested schema extension definition instances", () => { + const extension = new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); + + try { + Schemas.User.extend(extension); + assert.deepStrictEqual(Schemas.declared(), [Schemas.User.definition, extension], + "Static method 'declared' did not find nested schema extension definition instances"); + } finally { + Schemas.User.truncate(extension.id); + } + }); + }); +}); export const SchemasHooks = { construct: (TargetSchema, fixtures) => (() => { @@ -119,105 +230,4 @@ export const SchemasHooks = { "Definition did not match sample schema defined in RFC7643"); }); }) -}; - -export const SchemasSuite = () => { - it("should include static class 'Schemas'", () => - assert.ok(!!SCIMMY.Schemas, "Static class 'Schemas' not defined")); - - describe("SCIMMY.Schemas", () => { - describe(".declare()", () => { - it("should have static method 'declare'", () => { - assert.ok(typeof SCIMMY.Schemas.declare === "function", - "Static method 'declare' not defined"); - }); - - it("should expect 'definition' argument to be an instance of SchemaDefinition", () => { - assert.throws(() => SCIMMY.Schemas.declare(), - {name: "TypeError", message: "Registering schema definition must be of type 'SchemaDefinition'"}, - "Static method 'declare' did not expect 'definition' parameter to be specified"); - assert.throws(() => SCIMMY.Schemas.declare({}), - {name: "TypeError", message: "Registering schema definition must be of type 'SchemaDefinition'"}, - "Static method 'declare' did not expect 'definition' parameter to be an instance of SchemaDefinition"); - }); - - it("should always return self after declaration", () => { - assert.strictEqual(SCIMMY.Schemas.declare(SCIMMY.Schemas.User.definition), SCIMMY.Schemas, - "Static method 'declare' did not return Schemas for chaining"); - }); - - it("should ignore definition instances that are already declared with the same name", () => { - assert.doesNotThrow(() => SCIMMY.Schemas.declare(SCIMMY.Schemas.User.definition), - "Static method 'declare' did not ignore redeclaration of existing name/instance pair"); - }); - - it("should expect all schema definitions to have unique names", () => { - assert.throws(() => SCIMMY.Schemas.declare(SCIMMY.Schemas.EnterpriseUser.definition, "User"), - {name: "TypeError", message: `Schema definition 'User' already declared with id '${SCIMMY.Schemas.User.definition.id}'`}, - "Static method 'declare' did not expect schema definitions to have unique names"); - }); - - it("should not declare an existing schema definition under a new name", () => { - assert.throws(() => SCIMMY.Schemas.declare(SCIMMY.Schemas.User.definition, "Test"), - {name: "TypeError", message: `Schema definition '${SCIMMY.Schemas.User.definition.id}' already declared with name 'User'`}, - "Static method 'declare' did not prevent existing schema definition from declaring under a new name"); - }); - }); - - describe(".declared()", () => { - it("should have static method 'declared'", () => { - assert.ok(typeof SCIMMY.Schemas.declared === "function", - "Static method 'declared' not defined"); - }); - - it("should return all declared definitions when called without arguments", () => { - assert.deepStrictEqual(SCIMMY.Schemas.declared(), [SCIMMY.Schemas.User.definition], - "Static method 'declared' did not return all declared definitions when called without arguments"); - }); - - it("should return boolean 'false' when called with unexpected arguments", () => { - assert.strictEqual(SCIMMY.Schemas.declared({}), false, - "Static method 'declared' did not return boolean 'false' when called with unexpected arguments"); - }); - - it("should find declaration status of definitions by name", () => { - assert.ok(SCIMMY.Schemas.declared("User"), - "Static method 'declared' did not find declaration status of declared 'User' schema by name"); - assert.ok(!SCIMMY.Schemas.declared("EnterpriseUser"), - "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by name"); - }); - - it("should find declaration status of definitions by ID", () => { - assert.ok(SCIMMY.Schemas.declared(SCIMMY.Schemas.User.definition.id), - "Static method 'declared' did not find declaration status of declared 'User' schema by ID"); - assert.ok(!SCIMMY.Schemas.declared(SCIMMY.Schemas.EnterpriseUser.definition.id), - "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by ID"); - }); - - it("should find declaration status of definitions by instance", () => { - assert.ok(SCIMMY.Schemas.declared(SCIMMY.Schemas.User.definition), - "Static method 'declared' did not find declaration status of declared 'User' schema by instance"); - assert.ok(!SCIMMY.Schemas.declared(SCIMMY.Schemas.EnterpriseUser.definition), - "Static method 'declared' did not find declaration status of undeclared 'EnterpriseUser' schema by instance"); - }); - - it("should find nested schema extension definition instances", () => { - const extension = new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); - - try { - SCIMMY.Schemas.User.extend(extension); - assert.deepStrictEqual(SCIMMY.Schemas.declared(), [SCIMMY.Schemas.User.definition, extension], - "Static method 'declared' did not find nested schema extension definition instances"); - } finally { - SCIMMY.Schemas.User.truncate(extension.id); - } - }); - }); - - ResourceTypeSuite(); - ServiceProviderConfigSuite(); - UserSuite(); - GroupSuite(); - EnterpriseUserSuite(); - }); }; \ No newline at end of file diff --git a/test/lib/schemas/enterpriseuser.js b/test/lib/schemas/enterpriseuser.js index 2b7b1ad..6d93e26 100644 --- a/test/lib/schemas/enterpriseuser.js +++ b/test/lib/schemas/enterpriseuser.js @@ -1,19 +1,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {EnterpriseUser} from "#@/lib/schemas/enterpriseuser.js"; import {SchemasHooks} from "../schemas.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8").then((f) => JSON.parse(f)); -export const EnterpriseUserSuite = () => { - it("should include static class 'EnterpriseUser'", () => - assert.ok(!!SCIMMY.Schemas.EnterpriseUser, "Static class 'EnterpriseUser' not defined")); - - describe("SCIMMY.Schemas.EnterpriseUser", () => { - describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.EnterpriseUser, fixtures)); - describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.EnterpriseUser, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Schemas.EnterpriseUser", () => { + describe(".definition", SchemasHooks.definition(EnterpriseUser, fixtures)); + describe("@constructor", SchemasHooks.construct(EnterpriseUser, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/schemas/group.js b/test/lib/schemas/group.js index feb22f0..a8cb508 100644 --- a/test/lib/schemas/group.js +++ b/test/lib/schemas/group.js @@ -1,19 +1,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Group} from "#@/lib/schemas/group.js"; import {SchemasHooks} from "../schemas.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); -export const GroupSuite = () => { - it("should include static class 'Group'", () => - assert.ok(!!SCIMMY.Schemas.Group, "Static class 'Group' not defined")); - - describe("SCIMMY.Schemas.Group", () => { - describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.Group, fixtures)); - describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.Group, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Schemas.Group", () => { + describe(".definition", SchemasHooks.definition(Group, fixtures)); + describe("@constructor", SchemasHooks.construct(Group, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/schemas/resourcetype.js b/test/lib/schemas/resourcetype.js index d49eabc..46ee80f 100644 --- a/test/lib/schemas/resourcetype.js +++ b/test/lib/schemas/resourcetype.js @@ -1,19 +1,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {ResourceType} from "#@/lib/schemas/resourcetype.js"; import {SchemasHooks} from "../schemas.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); -export const ResourceTypeSuite = () => { - it("should include static class 'ResourceType'", () => - assert.ok(!!SCIMMY.Schemas.ResourceType, "Static class 'ResourceType' not defined")); - - describe("SCIMMY.Schemas.ResourceType", () => { - describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.ResourceType, fixtures)); - describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.ResourceType, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Schemas.ResourceType", () => { + describe(".definition", SchemasHooks.definition(ResourceType, fixtures)); + describe("@constructor", SchemasHooks.construct(ResourceType, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/schemas/spconfig.js b/test/lib/schemas/spconfig.js index 734b496..e45c1be 100644 --- a/test/lib/schemas/spconfig.js +++ b/test/lib/schemas/spconfig.js @@ -1,19 +1,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {ServiceProviderConfig} from "#@/lib/schemas/spconfig.js"; import {SchemasHooks} from "../schemas.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); -export const ServiceProviderConfigSuite = () => { - it("should include static class 'ServiceProviderConfig'", () => - assert.ok(!!SCIMMY.Schemas.ServiceProviderConfig, "Static class 'ServiceProviderConfig' not defined")); - - describe("SCIMMY.Schemas.ServiceProviderConfig", () => { - describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.ServiceProviderConfig, fixtures)); - describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.ServiceProviderConfig, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Schemas.ServiceProviderConfig", () => { + describe(".definition", SchemasHooks.definition(ServiceProviderConfig, fixtures)); + describe("@constructor", SchemasHooks.construct(ServiceProviderConfig, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/schemas/user.js b/test/lib/schemas/user.js index 505d3f7..7745fcf 100644 --- a/test/lib/schemas/user.js +++ b/test/lib/schemas/user.js @@ -1,19 +1,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {User} from "#@/lib/schemas/user.js"; import {SchemasHooks} from "../schemas.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); -export const UserSuite = () => { - it("should include static class 'User'", () => - assert.ok(!!SCIMMY.Schemas.User, "Static class 'User' not defined")); - - describe("SCIMMY.Schemas.User", () => { - describe("#constructor", SchemasHooks.construct(SCIMMY.Schemas.User, fixtures)); - describe(".definition", SchemasHooks.definition(SCIMMY.Schemas.User, fixtures)); - }); -}; \ No newline at end of file +describe("SCIMMY.Schemas.User", () => { + describe(".definition", SchemasHooks.definition(User, fixtures)); + describe("@constructor", SchemasHooks.construct(User, fixtures)); +}); \ No newline at end of file diff --git a/test/lib/types.js b/test/lib/types.js index c978445..a4ea044 100644 --- a/test/lib/types.js +++ b/test/lib/types.js @@ -1,22 +1,34 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; -import {AttributeSuite} from "./types/attribute.js"; -import {SchemaDefinitionSuite} from "./types/definition.js"; -import {FilterSuite} from "./types/filter.js"; -import {ErrorSuite} from "./types/error.js"; -import {SchemaSuite} from "./types/schema.js"; -import {ResourceSuite} from "./types/resource.js"; -export const TypesSuite = () => { - it("should include static class 'Types'", () => - assert.ok(!!SCIMMY.Types, "Static class 'Types' not defined")); +describe("SCIMMY.Types", () => { + it("should include static class 'Attribute'", () => { + assert.ok(!!SCIMMY.Types.Attribute, + "Static class 'Attribute' not defined"); + }); + + it("should include static class 'SchemaDefinition'", () => { + assert.ok(!!SCIMMY.Types.SchemaDefinition, + "Static class 'SchemaDefinition' not defined"); + }); + + it("should include static class 'Error'", () => { + assert.ok(!!SCIMMY.Types.Error, + "Static class 'Error' not defined"); + }); + + it("should include static class 'Filter'", () => { + assert.ok(!!SCIMMY.Types.Filter, + "Static class 'Filter' not defined"); + }); + + it("should include static class 'Resource'", () => { + assert.ok(!!SCIMMY.Types.Resource, + "Static class 'Resource' not defined"); + }); - describe("SCIMMY.Types", () => { - AttributeSuite(); - SchemaDefinitionSuite(); - FilterSuite(); - ErrorSuite(); - SchemaSuite(); - ResourceSuite(); + it("should include static class 'Schema'", () => { + assert.ok(!!SCIMMY.Types.Schema, + "Static class 'Schema' not defined"); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index d3feaca..2d5d8c8 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -2,7 +2,7 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Attribute} from "#@/lib/types/attribute.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./attribute.json"), "utf8").then((f) => JSON.parse(f)); @@ -10,7 +10,7 @@ const fixtures = fs.readFile(path.join(basepath, "./attribute.json"), "utf8").th export function instantiateFromFixture(fixture) { const {type, name, mutability: m, uniqueness: u, subAttributes = [], ...config} = fixture; - return new SCIMMY.Types.Attribute( + return new Attribute( type, name, {...(m !== undefined ? {mutable: m} : {}), ...(u !== null ? {uniqueness: !u ? false : u} : {}), ...config}, subAttributes.map(instantiateFromFixture) ); @@ -18,7 +18,7 @@ export function instantiateFromFixture(fixture) { // Run valid and invalid fixtures for different attribute types function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, assertion}) { - const attribute = new SCIMMY.Types.Attribute(type, "test", {...config, multiValued: multiValued}); + const attribute = new Attribute(type, "test", {...config, multiValued: multiValued}); const target = (multiValued ? attribute.coerce([]) : null); for (let [label, value] of valid) { @@ -33,414 +33,409 @@ function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, } } -export const AttributeSuite = () => { - it("should include static class 'Attribute'", () => - assert.ok(!!SCIMMY.Types.Attribute, "Static class 'Attribute' not defined")); +describe("SCIMMY.Types.Attribute", () => { + it("should require valid 'type' argument at instantiation", () => { + assert.throws(() => new Attribute(), + {name: "TypeError", message: "Required parameter 'type' missing from Attribute instantiation"}, + "Attribute instantiated without 'type' argument"); + assert.throws(() => new Attribute("other", "other"), + {name: "TypeError", message: "Type 'other' not recognised in attribute definition 'other'"}, + "Attribute instantiated with unknown 'type' argument"); + }); - describe("SCIMMY.Types.Attribute", () => { - it("should require valid 'type' argument at instantiation", () => { - assert.throws(() => new SCIMMY.Types.Attribute(), - {name: "TypeError", message: "Required parameter 'type' missing from Attribute instantiation"}, - "Attribute instantiated without 'type' argument"); - assert.throws(() => new SCIMMY.Types.Attribute("other", "other"), - {name: "TypeError", message: "Type 'other' not recognised in attribute definition 'other'"}, - "Attribute instantiated with unknown 'type' argument"); - }); + it("should require valid 'name' argument at instantiation", () => { + assert.throws(() => new Attribute("string"), + {name: "TypeError", message: "Required parameter 'name' missing from Attribute instantiation"}, + "Attribute instantiated without 'name' argument"); + + const invalidNames = [ + [".", "invalid.name"], + ["@", "invalid@name"], + ["=", "invalid=name"], + ["%", "invalid%name"] + ]; - it("should require valid 'name' argument at instantiation", () => { - assert.throws(() => new SCIMMY.Types.Attribute("string"), - {name: "TypeError", message: "Required parameter 'name' missing from Attribute instantiation"}, - "Attribute instantiated without 'name' argument"); + for (let [char, name] of invalidNames) { + assert.throws(() => new Attribute("string", name), + {name: "TypeError", message: `Invalid character '${char}' in name of attribute definition '${name}'`}, + "Attribute instantiated with invalid 'name' argument"); + } + + assert.ok(new Attribute("string", "validName"), + "Attribute did not instantiate with valid 'name' argument"); + }); - const invalidNames = [ - [".", "invalid.name"], - ["@", "invalid@name"], - ["=", "invalid=name"], - ["%", "invalid%name"] - ]; - - for (let [char, name] of invalidNames) { - assert.throws(() => new SCIMMY.Types.Attribute("string", name), - {name: "TypeError", message: `Invalid character '${char}' in name of attribute definition '${name}'`}, - "Attribute instantiated with invalid 'name' argument"); + it("should not accept 'subAttributes' argument if type is not 'complex'", () => { + assert.throws(() => new Attribute("string", "test", {}, [new Attribute("string", "other")]), + {name: "TypeError", message: "Attribute type must be 'complex' when subAttributes are specified in attribute definition 'test'"}, + "Attribute instantiated with subAttributes when type was not 'complex'"); + }); + + for (let attrib of ["canonicalValues", "referenceTypes"]) { + it(`should not accept invalid '${attrib}' configuration values`, () => { + for (let value of ["a string", true]) { + assert.throws(() => new Attribute("string", "test", {[attrib]: value}), + {name: "TypeError", message: `Attribute '${attrib}' value must be either a collection or 'false' in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration value '${value}'`); } - - assert.ok(new SCIMMY.Types.Attribute("string", "validName"), - "Attribute did not instantiate with valid 'name' argument"); }); - - it("should not accept 'subAttributes' argument if type is not 'complex'", () => { - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {}, [new SCIMMY.Types.Attribute("string", "other")]), - {name: "TypeError", message: "Attribute type must be 'complex' when subAttributes are specified in attribute definition 'test'"}, - "Attribute instantiated with subAttributes when type was not 'complex'"); + } + + for (let [attrib, name = attrib] of [["mutable", "mutability"], ["returned"], ["uniqueness"]]) { + it(`should not accept invalid '${attrib}' configuration values`, () => { + assert.throws(() => new Attribute("string", "test", {[attrib]: "a string"}), + {name: "TypeError", message: `Attribute '${name}' value 'a string' not recognised in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration value 'a string'`); + assert.throws(() => new Attribute("string", "test", {[attrib]: 1}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration number value '1'`); + assert.throws(() => new Attribute("string", "test", {[attrib]: {}}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration complex value`); + assert.throws(() => new Attribute("string", "test", {[attrib]: new Date()}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration date value`); }); + } + + it("should be frozen after instantiation", () => { + const attribute = new Attribute("string", "test"); - for (let attrib of ["canonicalValues", "referenceTypes"]) { - it(`should not accept invalid '${attrib}' configuration values`, () => { - for (let value of ["a string", true]) { - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {[attrib]: value}), - {name: "TypeError", message: `Attribute '${attrib}' value must be either a collection or 'false' in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration value '${value}'`); - } - }); - } - - for (let [attrib, name = attrib] of [["mutable", "mutability"], ["returned"], ["uniqueness"]]) { - it(`should not accept invalid '${attrib}' configuration values`, () => { - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {[attrib]: "a string"}), - {name: "TypeError", message: `Attribute '${name}' value 'a string' not recognised in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration value 'a string'`); - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {[attrib]: 1}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration number value '1'`); - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {[attrib]: {}}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration complex value`); - assert.throws(() => new SCIMMY.Types.Attribute("string", "test", {[attrib]: new Date()}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration date value`); - }); - } - - it("should be frozen after instantiation", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test"); - - assert.throws(() => attribute.test = true, - {name: "TypeError", message: "Cannot add property test, object is not extensible"}, - "Attribute was extensible after instantiation"); - assert.throws(() => attribute.name = "something", - {name: "TypeError", message: "Cannot assign to read only property 'name' of object '#'"}, - "Attribute properties were modifiable after instantiation"); - assert.throws(() => delete attribute.config, - {name: "TypeError", message: "Cannot delete property 'config' of #"}, - "Attribute was not sealed after instantiation"); + assert.throws(() => attribute.test = true, + {name: "TypeError", message: "Cannot add property test, object is not extensible"}, + "Attribute was extensible after instantiation"); + assert.throws(() => attribute.name = "something", + {name: "TypeError", message: "Cannot assign to read only property 'name' of object '#'"}, + "Attribute properties were modifiable after instantiation"); + assert.throws(() => delete attribute.config, + {name: "TypeError", message: "Cannot delete property 'config' of #"}, + "Attribute was not sealed after instantiation"); + }); + + describe("#toJSON()", () => { + it("should have instance method 'toJSON'", () => { + assert.ok(typeof (new Attribute("string", "test")).toJSON === "function", + "Instance method 'toJSON' not defined"); }); - describe("#toJSON()", () => { - it("should have instance method 'toJSON'", () => { - assert.ok(typeof (new SCIMMY.Types.Attribute("string", "test")).toJSON === "function", - "Instance method 'toJSON' not defined"); - }); + it("should produce valid SCIM attribute definition objects", async () => { + const {toJSON: suite} = await fixtures; - it("should produce valid SCIM attribute definition objects", async () => { - const {toJSON: suite} = await fixtures; + for (let fixture of suite) { + const attribute = instantiateFromFixture(fixture); - for (let fixture of suite) { - const attribute = instantiateFromFixture(fixture); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute)), fixture, - `Attribute 'toJSON' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM attribute definition object`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute)), fixture, + `Attribute 'toJSON' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM attribute definition object`); + } + }); + }); + + describe("#truncate()", () => { + it("should have instance method 'truncate'", () => { + assert.ok(typeof (new Attribute("string", "test")).truncate === "function", + "Instance method 'truncate' not defined"); }); - describe("#truncate()", () => { - it("should have instance method 'truncate'", () => { - assert.ok(typeof (new SCIMMY.Types.Attribute("string", "test")).truncate === "function", - "Instance method 'truncate' not defined"); - }); - - it("should do nothing without arguments", async () => { - const {truncate: suite} = await fixtures; - - for (let fixture of suite) { - const attribute = instantiateFromFixture(fixture); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate())), fixture, - `Attribute 'truncate' fixture #${suite.indexOf(fixture)+1} modified attribute without arguments`); - } - }); + it("should do nothing without arguments", async () => { + const {truncate: suite} = await fixtures; - it("should do nothing when type is not 'complex'", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test"); - const before = JSON.parse(JSON.stringify(attribute)); - const after = JSON.parse(JSON.stringify(attribute.truncate())); + for (let fixture of suite) { + const attribute = instantiateFromFixture(fixture); - assert.deepStrictEqual(after, before, - "Instance method 'truncate' modified non-complex attribute"); - }); - - it("should remove specified sub-attribute from 'subAttributes' collection", async () => { - const {truncate: suite} = await fixtures; - - for (let fixture of suite) { - const attribute = instantiateFromFixture(fixture); - const comparison = {...fixture, subAttributes: [...fixture.subAttributes ?? []]}; - const target = comparison.subAttributes.shift()?.name; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate(target))), comparison, - `Attribute 'truncate' fixture #${suite.indexOf(fixture) + 1} did not remove specified sub-attribute '${target}'`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate())), fixture, + `Attribute 'truncate' fixture #${suite.indexOf(fixture)+1} modified attribute without arguments`); + } }); - describe("#coerce()", () => { - it("should have instance method 'coerce'", () => { - assert.ok(typeof (new SCIMMY.Types.Attribute("string", "test")).coerce === "function", - "Instance method 'coerce' not defined"); - }); - - it("should expect required attributes to have a value", () => { - for (let type of ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"]) { - assert.throws(() => new SCIMMY.Types.Attribute(type, "test", {required: true}).coerce(), - {name: "TypeError", message: "Required attribute 'test' is missing"}, - `Instance method 'coerce' did not expect required ${type} attribute to have a value`); - assert.throws(() => new SCIMMY.Types.Attribute(type, "test", {required: true}).coerce(null), - {name: "TypeError", message: "Required attribute 'test' is missing"}, - `Instance method 'coerce' did not reject empty value 'null' when ${type} attribute was required`); - assert.throws(() => new SCIMMY.Types.Attribute(type, "test", {required: true, multiValued: true}).coerce(), - {name: "TypeError", message: "Required attribute 'test' is missing"}, - `Instance method 'coerce' did not expect required ${type} attribute to have a value`); - } - }); - - it("should expect value to be an array when attribute is multi-valued", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true}); - - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty value when attribute was not required"); - assert.ok(Array.isArray(attribute.coerce([])), - "Instance method 'coerce' did not produce array when attribute was multi-valued and value was array"); - assert.throws(() => attribute.coerce("a string"), - {name: "TypeError", message: "Attribute 'test' expected to be a collection"}, - "Instance method 'coerce' did not reject singular value 'a string'"); - assert.throws(() => attribute.coerce({}), - {name: "TypeError", message: "Attribute 'test' expected to be a collection"}, - "Instance method 'coerce' did not reject singular complex value"); - }); - - it("should expect value to be singular when attribute is not multi-valued", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test"); - - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty value when attribute was not required"); - assert.throws(() => attribute.coerce(["a string"]), - {name: "TypeError", message: "Attribute 'test' is not multi-valued and must not be a collection"}, - "Instance method 'coerce' did not reject array value ['a string']"); - assert.throws(() => attribute.coerce([{}]), - {name: "TypeError", message: "Attribute 'test' is not multi-valued and must not be a collection"}, - "Instance method 'coerce' did not reject array with complex value"); - }); + it("should do nothing when type is not 'complex'", () => { + const attribute = new Attribute("string", "test"); + const before = JSON.parse(JSON.stringify(attribute)); + const after = JSON.parse(JSON.stringify(attribute.truncate())); - it("should expect value to be canonical when attribute specifies canonicalValues characteristic", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test", {canonicalValues: ["Test"]}); - - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty non-canonical value"); - assert.throws(() => attribute.coerce("a string"), - {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, - "Instance method 'coerce' did not reject non-canonical value 'a string'"); - assert.doesNotThrow(() => attribute.coerce("Test"), - "Instance method 'coerce' rejected canonical value 'Test'"); - }); + assert.deepStrictEqual(after, before, + "Instance method 'truncate' modified non-complex attribute"); + }); + + it("should remove specified sub-attribute from 'subAttributes' collection", async () => { + const {truncate: suite} = await fixtures; - it("should expect all values to be canonical when attribute is multi-valued and specifies canonicalValues characteristic", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test", {multiValued: true, canonicalValues: ["Test"]}); - const target = attribute.coerce([]); + for (let fixture of suite) { + const attribute = instantiateFromFixture(fixture); + const comparison = {...fixture, subAttributes: [...fixture.subAttributes ?? []]}; + const target = comparison.subAttributes.shift()?.name; - assert.throws(() => attribute.coerce(["a string"]), - {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, - "Instance method 'coerce' did not reject non-canonical value 'a string' by itself"); - assert.throws(() => attribute.coerce(["Test", "a string"]), - {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, - `Instance method 'coerce' did not reject non-canonical value 'a string' with canonical value 'Test'`); - assert.doesNotThrow(() => attribute.coerce(["Test"]), - "Instance method 'coerce' rejected canonical value 'Test'"); - assert.throws(() => target.push("a string"), - {name: "TypeError", message: "Attribute 'test' does not include canonical value 'a string'"}, - "Instance method 'coerce' did not reject addition of non-canonical value 'a string' to coerced collection"); - }); - - it("should expect value to be a string when attribute type is 'string'", () => ( - typedCoercion("string", { - valid: [["string value 'a string'", "a string"]], - invalid: [ - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect all values to be strings when attribute is multi-valued and type is 'string'", () => ( - typedCoercion("string", { - multiValued: true, - valid: [["string value 'a string'", "a string"]], - invalid: [ - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect value to be either true or false when attribute type is 'boolean'", () => ( - typedCoercion("boolean", { - valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect all values to be either true or false when attribute is multi-valued and type is 'boolean'", () => ( - typedCoercion("boolean", { - multiValued: true, - valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect value to be a decimal number when attribute type is 'decimal'", () => ( - typedCoercion("decimal", { - valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["integer value '1'", "integer", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect all values to be decimal numbers when attribute is multi-valued and type is 'decimal'", () => ( - typedCoercion("decimal", { - multiValued: true, - valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["integer value '1'", "integer", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); + assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate(target))), comparison, + `Attribute 'truncate' fixture #${suite.indexOf(fixture) + 1} did not remove specified sub-attribute '${target}'`); + } + }); + }); + + describe("#coerce()", () => { + it("should have instance method 'coerce'", () => { + assert.ok(typeof (new Attribute("string", "test")).coerce === "function", + "Instance method 'coerce' not defined"); + }); + + it("should expect required attributes to have a value", () => { + for (let type of ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"]) { + assert.throws(() => new Attribute(type, "test", {required: true}).coerce(), + {name: "TypeError", message: "Required attribute 'test' is missing"}, + `Instance method 'coerce' did not expect required ${type} attribute to have a value`); + assert.throws(() => new Attribute(type, "test", {required: true}).coerce(null), + {name: "TypeError", message: "Required attribute 'test' is missing"}, + `Instance method 'coerce' did not reject empty value 'null' when ${type} attribute was required`); + assert.throws(() => new Attribute(type, "test", {required: true, multiValued: true}).coerce(), + {name: "TypeError", message: "Required attribute 'test' is missing"}, + `Instance method 'coerce' did not expect required ${type} attribute to have a value`); + } + }); + + it("should expect value to be an array when attribute is multi-valued", () => { + const attribute = new Attribute("string", "test", {multiValued: true}); - it("should expect value to be an integer number when attribute type is 'integer'", () => ( - typedCoercion("integer", { - valid: [["integer value '1'", 1]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["decimal value '1.01'", "decimal", 1.01], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); + assert.doesNotThrow(() => attribute.coerce(), + "Instance method 'coerce' rejected empty value when attribute was not required"); + assert.ok(Array.isArray(attribute.coerce([])), + "Instance method 'coerce' did not produce array when attribute was multi-valued and value was array"); + assert.throws(() => attribute.coerce("a string"), + {name: "TypeError", message: "Attribute 'test' expected to be a collection"}, + "Instance method 'coerce' did not reject singular value 'a string'"); + assert.throws(() => attribute.coerce({}), + {name: "TypeError", message: "Attribute 'test' expected to be a collection"}, + "Instance method 'coerce' did not reject singular complex value"); + }); + + it("should expect value to be singular when attribute is not multi-valued", () => { + const attribute = new Attribute("string", "test"); - it("should expect all values to be integer numbers when attribute is multi-valued and type is 'integer'", () => ( - typedCoercion("integer", { - multiValued: true, - valid: [["integer value '1'", 1]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["decimal value '1.01'", "decimal", 1.01], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); + assert.doesNotThrow(() => attribute.coerce(), + "Instance method 'coerce' rejected empty value when attribute was not required"); + assert.throws(() => attribute.coerce(["a string"]), + {name: "TypeError", message: "Attribute 'test' is not multi-valued and must not be a collection"}, + "Instance method 'coerce' did not reject array value ['a string']"); + assert.throws(() => attribute.coerce([{}]), + {name: "TypeError", message: "Attribute 'test' is not multi-valued and must not be a collection"}, + "Instance method 'coerce' did not reject array with complex value"); + }); + + it("should expect value to be canonical when attribute specifies canonicalValues characteristic", () => { + const attribute = new Attribute("string", "test", {canonicalValues: ["Test"]}); - it("should expect value to be a valid date instance or date string when attribute type is 'dateTime'", () => ( - typedCoercion("dateTime", { - valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}] - ] - }) - )); + assert.doesNotThrow(() => attribute.coerce(), + "Instance method 'coerce' rejected empty non-canonical value"); + assert.throws(() => attribute.coerce("a string"), + {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, + "Instance method 'coerce' did not reject non-canonical value 'a string'"); + assert.doesNotThrow(() => attribute.coerce("Test"), + "Instance method 'coerce' rejected canonical value 'Test'"); + }); + + it("should expect all values to be canonical when attribute is multi-valued and specifies canonicalValues characteristic", () => { + const attribute = new Attribute("string", "test", {multiValued: true, canonicalValues: ["Test"]}); + const target = attribute.coerce([]); - it("should expect all values to be valid date instances or date strings when attribute is multi-valued and type is 'dateTime'", () => ( - typedCoercion("dateTime", { - multiValued: true, - valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}] - ] - }) - )); + assert.throws(() => attribute.coerce(["a string"]), + {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, + "Instance method 'coerce' did not reject non-canonical value 'a string' by itself"); + assert.throws(() => attribute.coerce(["Test", "a string"]), + {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, + `Instance method 'coerce' did not reject non-canonical value 'a string' with canonical value 'Test'`); + assert.doesNotThrow(() => attribute.coerce(["Test"]), + "Instance method 'coerce' rejected canonical value 'Test'"); + assert.throws(() => target.push("a string"), + {name: "TypeError", message: "Attribute 'test' does not include canonical value 'a string'"}, + "Instance method 'coerce' did not reject addition of non-canonical value 'a string' to coerced collection"); + }); + + it("should expect value to be a string when attribute type is 'string'", () => ( + typedCoercion("string", { + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be strings when attribute is multi-valued and type is 'string'", () => ( + typedCoercion("string", { + multiValued: true, + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect value to be either true or false when attribute type is 'boolean'", () => ( + typedCoercion("boolean", { + valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be either true or false when attribute is multi-valued and type is 'boolean'", () => ( + typedCoercion("boolean", { + multiValued: true, + valid: [["boolean value 'true'", true], ["boolean value 'false'", false]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect value to be a decimal number when attribute type is 'decimal'", () => ( + typedCoercion("decimal", { + valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["integer value '1'", "integer", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be decimal numbers when attribute is multi-valued and type is 'decimal'", () => ( + typedCoercion("decimal", { + multiValued: true, + valid: [["decimal value '1.0'", Number(1.0).toFixed(1)], ["decimal value '1.01'", 1.01]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["integer value '1'", "integer", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect value to be an integer number when attribute type is 'integer'", () => ( + typedCoercion("integer", { + valid: [["integer value '1'", 1]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["decimal value '1.01'", "decimal", 1.01], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be integer numbers when attribute is multi-valued and type is 'integer'", () => ( + typedCoercion("integer", { + multiValued: true, + valid: [["integer value '1'", 1]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["decimal value '1.01'", "decimal", 1.01], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect value to be a valid date instance or date string when attribute type is 'dateTime'", () => ( + typedCoercion("dateTime", { + valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}] + ] + }) + )); + + it("should expect all values to be valid date instances or date strings when attribute is multi-valued and type is 'dateTime'", () => ( + typedCoercion("dateTime", { + multiValued: true, + valid: [["date instance value", new Date()], ["date string value", new Date().toISOString()]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}] + ] + }) + )); + + it("should expect value to be a valid reference when attribute type is 'reference'", () => { + assert.throws(() => new Attribute("reference", "test").coerce("a string"), + {name: "TypeError", message: "Attribute 'test' with type 'reference' does not specify any referenceTypes"}, + "Instance method 'coerce' did not expect reference value when attribute type was reference"); - it("should expect value to be a valid reference when attribute type is 'reference'", () => { - assert.throws(() => new SCIMMY.Types.Attribute("reference", "test").coerce("a string"), - {name: "TypeError", message: "Attribute 'test' with type 'reference' does not specify any referenceTypes"}, - "Instance method 'coerce' did not expect reference value when attribute type was reference"); - - typedCoercion("reference", { - config: {referenceTypes: ["uri", "external"]}, - valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], - invalid: [ - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }); + typedCoercion("reference", { + config: {referenceTypes: ["uri", "external"]}, + valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], + invalid: [ + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] }); + }); + + it("should expect all values to be valid references when attribute is multi-valued and type is 'reference'", () => { + assert.throws(() => new Attribute("reference", "test", {multiValued: true}).coerce([]).push("a string"), + {name: "TypeError", message: "Attribute 'test' with type 'reference' does not specify any referenceTypes"}, + "Instance method 'coerce' did not expect reference value when attribute type was reference"); - it("should expect all values to be valid references when attribute is multi-valued and type is 'reference'", () => { - assert.throws(() => new SCIMMY.Types.Attribute("reference", "test", {multiValued: true}).coerce([]).push("a string"), - {name: "TypeError", message: "Attribute 'test' with type 'reference' does not specify any referenceTypes"}, - "Instance method 'coerce' did not expect reference value when attribute type was reference"); - - typedCoercion("reference", { - multiValued: true, - config: {referenceTypes: ["uri", "external"]}, - valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], - invalid: [ - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["complex value", "complex", {}], - ["Date instance value", "dateTime", new Date()] - ] - }); + typedCoercion("reference", { + multiValued: true, + config: {referenceTypes: ["uri", "external"]}, + valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], + invalid: [ + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] }); - - it("should expect value to be an object when attribute type is 'complex'", () => ( - typedCoercion("complex", { - assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, - valid: [["complex value", {}]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); - - it("should expect all values to be objects when attribute is multi-valued and type is 'complex'", () => ( - typedCoercion("complex", { - multiValued: true, - assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, - valid: [["complex value", {}]], - invalid: [ - ["string value 'a string'", "string", "a string"], - ["number value '1'", "number", 1], - ["boolean value 'false'", "boolean", false], - ["Date instance value", "dateTime", new Date()] - ] - }) - )); }); + + it("should expect value to be an object when attribute type is 'complex'", () => ( + typedCoercion("complex", { + assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, + valid: [["complex value", {}]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be objects when attribute is multi-valued and type is 'complex'", () => ( + typedCoercion("complex", { + multiValued: true, + assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, + valid: [["complex value", {}]], + invalid: [ + ["string value 'a string'", "string", "a string"], + ["number value '1'", "number", 1], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index ec83ae7..031a237 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -3,415 +3,413 @@ import path from "path"; import url from "url"; import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +import {SchemaDefinition} from "#@/lib/types/definition.js"; +import {Attribute} from "#@/lib/types/attribute.js"; +import {Filter} from "#@/lib/types/filter.js"; import {instantiateFromFixture} from "./attribute.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./definition.json"), "utf8").then((f) => JSON.parse(f)); const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test"}; -export const SchemaDefinitionSuite = () => { - it("should include static class 'SchemaDefinition'", () => - assert.ok(!!SCIMMY.Types.SchemaDefinition, "Static class 'SchemaDefinition' not defined")); +describe("SCIMMY.Types.SchemaDefinition", () => { + it("should require valid 'name' argument at instantiation", () => { + assert.throws(() => (new SchemaDefinition()), + {name: "TypeError", message: "Required parameter 'name' missing from SchemaDefinition instantiation"}, + "SchemaDefinition instantiated without 'name' parameter"); + assert.throws(() => (new SchemaDefinition("")), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with empty string 'name' parameter"); + assert.throws(() => (new SchemaDefinition(false)), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'name' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition({})), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'name' parameter value"); + }); - describe("SCIMMY.Types.SchemaDefinition", () => { - it("should require valid 'name' argument at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition()), - {name: "TypeError", message: "Required parameter 'name' missing from SchemaDefinition instantiation"}, - "SchemaDefinition instantiated without 'name' parameter"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("")), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with empty string 'name' parameter"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition(false)), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'name' parameter boolean value 'false'"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition({})), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'name' parameter value"); - }); + it("should require valid 'id' argument at instantiation", () => { + assert.throws(() => (new SchemaDefinition("Test")), + {name: "TypeError", message: "Required parameter 'id' missing from SchemaDefinition instantiation"}, + "SchemaDefinition instantiated without 'id' parameter"); + assert.throws(() => (new SchemaDefinition("Test", "")), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with empty string 'id' parameter"); + assert.throws(() => (new SchemaDefinition("Test", false)), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'id' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition("Test", {})), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'id' parameter value"); + }); + + it("should require 'id' to start with 'urn:ietf:params:scim:schemas:' at instantiation", () => { + assert.throws(() => (new SchemaDefinition("Test", "test")), + {name: "TypeError", message: "Invalid SCIM schema URN namespace 'test' in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with invalid 'id' parameter value 'test'"); + }); + + it("should require valid 'description' argument at instantiation", () => { + assert.throws(() => (new SchemaDefinition(...Object.values(params), false)), + {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'description' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition(...Object.values(params), {})), + {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'description' parameter value"); + }); + + it("should have instance member 'name'", () => { + assert.strictEqual((new SchemaDefinition(...Object.values(params)))?.name, params.name, + "SchemaDefinition did not include instance member 'name'"); + }); + + it("should have instance member 'id'", () => { + assert.strictEqual((new SchemaDefinition(...Object.values(params)))?.id, params.id, + "SchemaDefinition did not include instance member 'id'"); + }); + + it("should have instance member 'description'", () => { + assert.ok("description" in (new SchemaDefinition(...Object.values(params))), + "SchemaDefinition did not include instance member 'description'"); + }); + + it("should have instance member 'attributes' that is an array", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should require valid 'id' argument at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test")), - {name: "TypeError", message: "Required parameter 'id' missing from SchemaDefinition instantiation"}, - "SchemaDefinition instantiated without 'id' parameter"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test", "")), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with empty string 'id' parameter"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test", false)), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'id' parameter boolean value 'false'"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test", {})), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'id' parameter value"); + assert.ok("attributes" in definition, + "SchemaDefinition did not include instance member 'attributes'"); + assert.ok(Array.isArray(definition.attributes), + "SchemaDefinition instance member 'attributes' was not an array"); + }); + + describe("#describe()", () => { + it("should have instance method 'describe'", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).describe === "function", + "Instance method 'describe' not defined"); }); - it("should require 'id' to start with 'urn:ietf:params:scim:schemas:' at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test", "test")), - {name: "TypeError", message: "Invalid SCIM schema URN namespace 'test' in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with invalid 'id' parameter value 'test'"); + it("should produce valid SCIM schema definition objects", async () => { + const {describe: suite} = await fixtures; + + for (let fixture of suite) { + const definition = new SchemaDefinition( + fixture.source.name, fixture.source.id, fixture.source.description, + fixture.source.attributes.map((a) => instantiateFromFixture(a)) + ); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(definition.describe())), fixture.target, + `SchemaDefinition 'describe' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM schema definition object`); + } }); - - it("should require valid 'description' argument at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition(...Object.values(params), false)), - {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'description' parameter boolean value 'false'"); - assert.throws(() => (new SCIMMY.Types.SchemaDefinition(...Object.values(params), {})), - {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'description' parameter value"); + }); + + describe("#attribute()", () => { + it("should have instance method 'attribute'", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).attribute === "function", + "Instance method 'attribute' not defined"); }); - it("should have instance member 'name'", () => { - assert.strictEqual((new SCIMMY.Types.SchemaDefinition(...Object.values(params)))?.name, params.name, - "SchemaDefinition did not include instance member 'name'"); + it("should find attributes by name", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("id"); + + assert.ok(attribute !== undefined, + "Instance method 'attribute' did not return anything"); + assert.ok(attribute instanceof Attribute, + "Instance method 'attribute' did not return an instance of 'Attribute'"); + assert.strictEqual(attribute.name, "id", + "Instance method 'attribute' did not find attribute with name 'id'"); }); - it("should have instance member 'id'", () => { - assert.strictEqual((new SCIMMY.Types.SchemaDefinition(...Object.values(params)))?.id, params.id, - "SchemaDefinition did not include instance member 'id'"); + it("should expect attributes to exist", () => { + assert.throws(() => (new SchemaDefinition(...Object.values(params))).attribute("test"), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, + "Instance method 'attribute' did not expect attribute 'test' to exist"); }); - it("should have instance member 'description'", () => { - assert.ok("description" in (new SCIMMY.Types.SchemaDefinition(...Object.values(params))), - "SchemaDefinition did not include instance member 'description'"); + it("should ignore case of 'name' argument when finding attributes", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("id"); + + assert.strictEqual(definition.attribute("ID"), attribute, + "Instance method 'attribute' did not ignore case of 'name' argument when finding attributes"); }); - it("should have instance member 'attributes' that is an array", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + it("should find sub-attributes by name", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); - assert.ok("attributes" in definition, - "SchemaDefinition did not include instance member 'attributes'"); - assert.ok(Array.isArray(definition.attributes), - "SchemaDefinition instance member 'attributes' was not an array"); + assert.ok(attribute !== undefined, + "Instance method 'attribute' did not return anything"); + assert.ok(attribute instanceof Attribute, + "Instance method 'attribute' did not return an instance of 'Attribute'"); + assert.strictEqual(attribute.name, "resourceType", + "Instance method 'attribute' did not find sub-attribute with name 'resourceType'"); }); - describe("#describe()", () => { - it("should have instance method 'describe'", () => { - assert.ok(typeof (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).describe === "function", - "Instance method 'describe' not defined"); - }); + it("should expect sub-attributes to exist", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should produce valid SCIM schema definition objects", async () => { - const {describe: suite} = await fixtures; - - for (let fixture of suite) { - const definition = new SCIMMY.Types.SchemaDefinition( - fixture.source.name, fixture.source.id, fixture.source.description, - fixture.source.attributes.map((a) => instantiateFromFixture(a)) - ); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(definition.describe())), fixture.target, - `SchemaDefinition 'describe' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM schema definition object`); - } - }); + assert.throws(() => definition.attribute("id.test"), + {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, + "Instance method 'attribute' did not expect sub-attribute 'id.test' to exist"); + assert.throws(() => definition.attribute("meta.test"), + {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, + "Instance method 'attribute' did not expect sub-attribute 'meta.test' to exist"); }); - describe("#attribute()", () => { - it("should have instance method 'attribute'", () => { - assert.ok(typeof (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).attribute === "function", - "Instance method 'attribute' not defined"); - }); - - it("should find attributes by name", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute("id"); - - assert.ok(attribute !== undefined, - "Instance method 'attribute' did not return anything"); - assert.ok(attribute instanceof SCIMMY.Types.Attribute, - "Instance method 'attribute' did not return an instance of 'SCIMMY.Types.Attribute'"); - assert.strictEqual(attribute.name, "id", - "Instance method 'attribute' did not find attribute with name 'id'"); - }); - - it("should expect attributes to exist", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).attribute("test"), - {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, - "Instance method 'attribute' did not expect attribute 'test' to exist"); - }); - - it("should ignore case of 'name' argument when finding attributes", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute("id"); - - assert.strictEqual(definition.attribute("ID"), attribute, - "Instance method 'attribute' did not ignore case of 'name' argument when finding attributes"); - }); + it("should ignore case of 'name' argument when finding sub-attributes", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); - it("should find sub-attributes by name", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute("meta.resourceType"); - - assert.ok(attribute !== undefined, - "Instance method 'attribute' did not return anything"); - assert.ok(attribute instanceof SCIMMY.Types.Attribute, - "Instance method 'attribute' did not return an instance of 'SCIMMY.Types.Attribute'"); - assert.strictEqual(attribute.name, "resourceType", - "Instance method 'attribute' did not find sub-attribute with name 'resourceType'"); - }); - - it("should expect sub-attributes to exist", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.attribute("id.test"), - {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, - "Instance method 'attribute' did not expect sub-attribute 'id.test' to exist"); - assert.throws(() => definition.attribute("meta.test"), - {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, - "Instance method 'attribute' did not expect sub-attribute 'meta.test' to exist"); - }); - - it("should ignore case of 'name' argument when finding sub-attributes", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute("meta.resourceType"); - - assert.strictEqual(definition.attribute("Meta.ResourceType"), attribute, - "Instance method 'attribute' did not ignore case of 'name' argument when finding sub-attributes"); - }); + assert.strictEqual(definition.attribute("Meta.ResourceType"), attribute, + "Instance method 'attribute' did not ignore case of 'name' argument when finding sub-attributes"); + }); + + it("should find namespaced attributes", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute(`${params.id}:id`); - it("should find namespaced attributes", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute(`${params.id}:id`); - - assert.ok(attribute !== undefined, - "Instance method 'attribute' did not return anything"); - assert.ok(attribute instanceof SCIMMY.Types.Attribute, - "Instance method 'attribute' did not return an instance of 'SCIMMY.Types.Attribute'"); - assert.strictEqual(attribute.name, "id", - "Instance method 'attribute' did not find namespaced attribute with name 'id'"); - }); + assert.ok(attribute !== undefined, + "Instance method 'attribute' did not return anything"); + assert.ok(attribute instanceof Attribute, + "Instance method 'attribute' did not return an instance of 'Attribute'"); + assert.strictEqual(attribute.name, "id", + "Instance method 'attribute' did not find namespaced attribute with name 'id'"); + }); + + it("should expect namespaced attributes to exist", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should expect namespaced attributes to exist", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.attribute(`${params.id}:test`), - {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, - `Instance method 'attribute' did not expect namespaced attribute '${params.id}:test' to exist`); - assert.throws(() => definition.attribute(`${params.id}:id.test`), - {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, - `Instance method 'attribute' did not expect namespaced attribute '${params.id}:id.test' to exist`); - assert.throws(() => definition.attribute(`${params.id}:meta.test`), - {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, - `Instance method 'attribute' did not expect namespaced attribute '${params.id}:meta.test' to exist`); - assert.throws(() => definition.attribute(`${params.id}Extension:test`), - {name: "TypeError", message: `Schema definition '${params.id}' does not declare schema extension for namespaced target '${params.id}Extension:test'`}, - `Instance method 'attribute' did not expect schema extension namespace for attribute '${params.id}Extension:test' to exist`); - }); + assert.throws(() => definition.attribute(`${params.id}:test`), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, + `Instance method 'attribute' did not expect namespaced attribute '${params.id}:test' to exist`); + assert.throws(() => definition.attribute(`${params.id}:id.test`), + {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, + `Instance method 'attribute' did not expect namespaced attribute '${params.id}:id.test' to exist`); + assert.throws(() => definition.attribute(`${params.id}:meta.test`), + {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, + `Instance method 'attribute' did not expect namespaced attribute '${params.id}:meta.test' to exist`); + assert.throws(() => definition.attribute(`${params.id}Extension:test`), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare schema extension for namespaced target '${params.id}Extension:test'`}, + `Instance method 'attribute' did not expect schema extension namespace for attribute '${params.id}Extension:test' to exist`); + }); + + it("should ignore case of 'name' argument when finding namespaced attributes", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute(`${params.id}:id`); - it("should ignore case of 'name' argument when finding namespaced attributes", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const attribute = definition.attribute(`${params.id}:id`); - - assert.strictEqual(definition.attribute(String(`${params.id}:id`).toUpperCase()), attribute, - "Instance method 'attribute' did not ignore case of 'name' argument when finding namespaced attributes"); - }); + assert.strictEqual(definition.attribute(String(`${params.id}:id`).toUpperCase()), attribute, + "Instance method 'attribute' did not ignore case of 'name' argument when finding namespaced attributes"); + }); + }); + + describe("#extend()", () => { + it("should have instance method 'extend'", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).extend === "function", + "Instance method 'extend' not defined"); }); - describe("#extend()", () => { - it("should have instance method 'extend'", () => { - assert.ok(typeof (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).extend === "function", - "Instance method 'extend' not defined"); - }); + it("should expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.extend({}), - {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, - "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); - assert.throws(() => definition.extend([new SCIMMY.Types.Attribute("string", "test"), {}]), - {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, - "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); - assert.throws(() => definition.extend([new SCIMMY.Types.Attribute("string", "test"), SCIMMY.Schemas.User.definition]), - {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, - "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); - }); + assert.throws(() => definition.extend({}), + {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, + "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); + assert.throws(() => definition.extend([new Attribute("string", "test"), {}]), + {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, + "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); + assert.throws(() => definition.extend([new Attribute("string", "test"), SCIMMY.Schemas.User.definition]), + {name: "TypeError", message: "Expected 'extension' to be a SchemaDefinition or collection of Attribute instances"}, + "Instance method 'extend' did not expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances"); + }); + + it("should expect all attribute extensions to have unique names", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should expect all attribute extensions to have unique names", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.extend(new SCIMMY.Types.Attribute("string", "id")), - {name: "TypeError", message: `Schema definition '${params.id}' already declares attribute 'id'`}, - "Instance method 'extend' did not expect Attribute instances in 'extension' argument to have unique names"); - }); + assert.throws(() => definition.extend(new Attribute("string", "id")), + {name: "TypeError", message: `Schema definition '${params.id}' already declares attribute 'id'`}, + "Instance method 'extend' did not expect Attribute instances in 'extension' argument to have unique names"); + }); + + it("should do nothing when Attribute instance extensions are already included in the schema definition", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const extension = new Attribute("string", "test"); + const attribute = definition.attribute("id"); - it("should do nothing when Attribute instance extensions are already included in the schema definition", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const extension = new SCIMMY.Types.Attribute("string", "test"); - const attribute = definition.attribute("id"); - - assert.strictEqual(definition.extend([attribute, extension]).attribute("id"), attribute, - "Instance method 'extend' did not ignore already included Attribute instance extension"); - assert.strictEqual(definition.extend(extension).attribute("test"), extension, - "Instance method 'extend' did not ignore already included Attribute instance extension"); - }); + assert.strictEqual(definition.extend([attribute, extension]).attribute("id"), attribute, + "Instance method 'extend' did not ignore already included Attribute instance extension"); + assert.strictEqual(definition.extend(extension).attribute("test"), extension, + "Instance method 'extend' did not ignore already included Attribute instance extension"); + }); + + it("should expect all schema definition extensions to have unique IDs", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const extension = new SchemaDefinition("ExtensionTest", SCIMMY.Schemas.User.definition.id); - it("should expect all schema definition extensions to have unique IDs", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const extension = new SCIMMY.Types.SchemaDefinition("ExtensionTest", SCIMMY.Schemas.User.definition.id); - - assert.throws(() => definition.extend(SCIMMY.Schemas.User.definition).extend(extension), - {name: "TypeError", message: `Schema definition '${params.id}' already declares extension '${SCIMMY.Schemas.User.definition.id}'`}, - "Instance method 'extend' did not expect 'extension' argument of type SchemaDefinition to have unique id"); - }); + assert.throws(() => definition.extend(SCIMMY.Schemas.User.definition).extend(extension), + {name: "TypeError", message: `Schema definition '${params.id}' already declares extension '${SCIMMY.Schemas.User.definition.id}'`}, + "Instance method 'extend' did not expect 'extension' argument of type SchemaDefinition to have unique id"); + }); + + it("should do nothing when SchemaDefinition instances are already declared as extensions to the schema definition", () => { + const extension = new SchemaDefinition(`${params.name}Extension`, `${params.id}Extension`); + const definition = new SchemaDefinition(...Object.values(params)).extend(extension); - it("should do nothing when SchemaDefinition instances are already declared as extensions to the schema definition", () => { - const extension = new SCIMMY.Types.SchemaDefinition(`${params.name}Extension`, `${params.id}Extension`); - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)).extend(extension); - - assert.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension, - "Instance method 'extend' did not ignore already declared SchemaDefinition extension"); - }); + assert.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension, + "Instance method 'extend' did not ignore already declared SchemaDefinition extension"); + }); + }); + + describe("#truncate()", () => { + it("should have instance method 'truncate'", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).truncate === "function", + "Instance method 'truncate' not defined"); }); - describe("#truncate()", () => { - it("should have instance method 'truncate'", () => { - assert.ok(typeof (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).truncate === "function", - "Instance method 'truncate' not defined"); - }); + it("should do nothing without arguments", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const actual = JSON.parse(JSON.stringify(definition.truncate().describe())); - it("should do nothing without arguments", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const expected = JSON.parse(JSON.stringify(definition.describe())); - const actual = JSON.parse(JSON.stringify(definition.truncate().describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' modified attributes without arguments"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' modified attributes without arguments"); + }); + + it("should do nothing when definition does not directly include Attribute instances in 'attributes' argument", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const attribute = new Attribute("string", "id"); + const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - it("should do nothing when definition does not directly include Attribute instances in 'attributes' argument", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const expected = JSON.parse(JSON.stringify(definition.describe())); - const attribute = new SCIMMY.Types.Attribute("string", "id"); - const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); + }); + + it("should remove Attribute instances directly included in the definition", () => { + const attribute = new Attribute("string", "test"); + const definition = new SchemaDefinition(...Object.values(params), "", [attribute]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - it("should remove Attribute instances directly included in the definition", () => { - const attribute = new SCIMMY.Types.Attribute("string", "test"); - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [attribute]); - const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); - const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); + }); + + it("should remove named attributes directly included in the definition", () => { + const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("string", "test")]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); - it("should remove named attributes directly included in the definition", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "", [new SCIMMY.Types.Attribute("string", "test")]); - const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); - const actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not remove named attribute directly included in the definition"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove named attribute directly included in the definition"); + }); + + it("should expect named attributes and sub-attributes to exist", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should expect named attributes and sub-attributes to exist", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.truncate("test"), - {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, - "Instance method 'truncate' did not expect named attribute 'test' to exist"); - assert.throws(() => definition.truncate("id.test"), - {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, - "Instance method 'truncate' did not expect named sub-attribute 'id.test' to exist"); - assert.throws(() => definition.truncate("meta.test"), - {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, - "Instance method 'truncate' did not expect named sub-attribute 'meta.test' to exist"); - }); + assert.throws(() => definition.truncate("test"), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, + "Instance method 'truncate' did not expect named attribute 'test' to exist"); + assert.throws(() => definition.truncate("id.test"), + {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, + "Instance method 'truncate' did not expect named sub-attribute 'id.test' to exist"); + assert.throws(() => definition.truncate("meta.test"), + {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, + "Instance method 'truncate' did not expect named sub-attribute 'meta.test' to exist"); + }); + }); + + describe("#coerce()", () => { + it("should have instance method 'coerce'", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).coerce === "function", + "Instance method 'coerce' not defined"); }); - describe("#coerce()", () => { - it("should have instance method 'coerce'", () => { - assert.ok(typeof (new SCIMMY.Types.SchemaDefinition(...Object.values(params))).coerce === "function", - "Instance method 'coerce' not defined"); - }); + it("should expect 'data' argument to be an object", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should expect 'data' argument to be an object", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - assert.throws(() => definition.coerce(), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Instance method 'coerce' did not expect 'data' argument to be defined"); - assert.throws(() => definition.coerce("a string"), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Instance method 'coerce' did not fail with 'data' argument string value 'a string'"); - assert.throws(() => definition.coerce([]), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Instance method 'coerce' did not fail with 'data' argument array value"); - }); + assert.throws(() => definition.coerce(), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Instance method 'coerce' did not expect 'data' argument to be defined"); + assert.throws(() => definition.coerce("a string"), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Instance method 'coerce' did not fail with 'data' argument string value 'a string'"); + assert.throws(() => definition.coerce([]), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Instance method 'coerce' did not fail with 'data' argument array value"); + }); + + it("should expect common attributes to be defined on coerced result", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const result = definition.coerce({}); - it("should expect common attributes to be defined on coerced result", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - const result = definition.coerce({}); - - assert.ok(Array.isArray(result.schemas) && result.schemas.includes(params.id), - "Instance method 'coerce' did not set common attribute 'schemas' on coerced result"); - assert.strictEqual(result?.meta?.resourceType, params.name, - "Instance method 'coerce' did not set common attribute 'meta.resourceType' on coerced result"); - }); + assert.ok(Array.isArray(result.schemas) && result.schemas.includes(params.id), + "Instance method 'coerce' did not set common attribute 'schemas' on coerced result"); + assert.strictEqual(result?.meta?.resourceType, params.name, + "Instance method 'coerce' did not set common attribute 'meta.resourceType' on coerced result"); + }); + + it("should expect coerce to be called on directly included attributes", () => { + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [ + new Attribute("string", "test", {required: true}) + ]); - it("should expect coerce to be called on directly included attributes", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [ - new SCIMMY.Types.Attribute("string", "test", {required: true}) - ]); - - assert.throws(() => definition.coerce({}), - {name: "TypeError", message: "Required attribute 'test' is missing"}, - "Instance method 'coerce' did not attempt to coerce required attribute 'test'"); - assert.throws(() => definition.coerce({test: false}), - {name: "TypeError", message: "Attribute 'test' expected value type 'string' but found type 'boolean'"}, - "Instance method 'coerce' did not reject boolean value 'false' for string attribute 'test'"); - }); + assert.throws(() => definition.coerce({}), + {name: "TypeError", message: "Required attribute 'test' is missing"}, + "Instance method 'coerce' did not attempt to coerce required attribute 'test'"); + assert.throws(() => definition.coerce({test: false}), + {name: "TypeError", message: "Attribute 'test' expected value type 'string' but found type 'boolean'"}, + "Instance method 'coerce' did not reject boolean value 'false' for string attribute 'test'"); + }); + + it("should expect namespaced attributes or extensions to be coerced", () => { + const definition = new SchemaDefinition(...Object.values(params)) + .extend(SCIMMY.Schemas.EnterpriseUser.definition, true); - it("should expect namespaced attributes or extensions to be coerced", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)) - .extend(SCIMMY.Schemas.EnterpriseUser.definition, true); - - assert.throws(() => definition.coerce({}), - {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, - "Instance method 'coerce' did not attempt to coerce required schema extension"); - assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {}}), - {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, - "Instance method 'coerce' did not attempt to coerce required schema extension"); - assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {employeeNumber: "1234"}}), - "Instance method 'coerce' failed to coerce required schema extension value"); - assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: "1234"}), - "Instance method 'coerce' failed to coerce required schema extension value"); - assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: false}), - {name: "TypeError", message: `Attribute 'employeeNumber' expected value type 'string' but found type 'boolean' in schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, - "Instance method 'coerce' did not attempt to coerce required schema extension's invalid value"); - }); + assert.throws(() => definition.coerce({}), + {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + "Instance method 'coerce' did not attempt to coerce required schema extension"); + assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {}}), + {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + "Instance method 'coerce' did not attempt to coerce required schema extension"); + assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {employeeNumber: "1234"}}), + "Instance method 'coerce' failed to coerce required schema extension value"); + assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: "1234"}), + "Instance method 'coerce' failed to coerce required schema extension value"); + assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: false}), + {name: "TypeError", message: `Attribute 'employeeNumber' expected value type 'string' but found type 'boolean' in schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + "Instance method 'coerce' did not attempt to coerce required schema extension's invalid value"); + }); + + it("should expect the supplied filter to be applied to coerced result", () => { + const attributes = [new Attribute("string", "testName"), new Attribute("string", "testValue")]; + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes); + const result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new Filter("testName pr")); - it("should expect the supplied filter to be applied to coerced result", () => { - const attributes = [new SCIMMY.Types.Attribute("string", "testName"), new SCIMMY.Types.Attribute("string", "testValue")]; - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", attributes); - const result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new SCIMMY.Types.Filter("testName pr")); - - assert.ok(Object.keys(result).includes("testName"), - "Instance method 'coerce' did not include attributes for filter 'testName pr'"); - assert.ok(!Object.keys(result).includes("testValue"), - "Instance method 'coerce' included attributes not specified for filter 'testName pr'"); - }); + assert.ok(Object.keys(result).includes("testName"), + "Instance method 'coerce' did not include attributes for filter 'testName pr'"); + assert.ok(!Object.keys(result).includes("testValue"), + "Instance method 'coerce' included attributes not specified for filter 'testName pr'"); + }); + + it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [new Attribute("string", "employeeNumber")]) + .extend(SCIMMY.Schemas.EnterpriseUser.definition); + const result = definition.coerce( + { + employeeNumber: "Test", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", + }, + undefined, undefined, + new Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") + ); - it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { - const definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params), "Test Schema", [new SCIMMY.Types.Attribute("string", "employeeNumber")]) - .extend(SCIMMY.Schemas.EnterpriseUser.definition); - const result = definition.coerce( - { - employeeNumber: "Test", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", - }, - undefined, undefined, - new SCIMMY.Types.Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") - ); - - assert.strictEqual(result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].employeeNumber, "1234", - "Instance method 'coerce' did not include namespaced attributes for filter"); - assert.ok(!Object.keys(result).includes("testName"), - "Instance method 'coerce' included namespaced attributes not specified for filter"); - }); + assert.strictEqual(result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].employeeNumber, "1234", + "Instance method 'coerce' did not include namespaced attributes for filter"); + assert.ok(!Object.keys(result).includes("testName"), + "Instance method 'coerce' included namespaced attributes not specified for filter"); }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/error.js b/test/lib/types/error.js index 6675f87..2236081 100644 --- a/test/lib/types/error.js +++ b/test/lib/types/error.js @@ -1,39 +1,34 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {SCIMError} from "#@/lib/types/error.js"; -export const ErrorSuite = () => { - it("should include static class 'Error'", () => - assert.ok(!!SCIMMY.Types.Error, "Static class 'Error' not defined")); +describe("SCIMMY.Types.Error", () => { + it("should not require arguments at instantiation", () => { + assert.doesNotThrow(() => new SCIMError(), + "Error type class did not instantiate without arguments"); + }); + + it("should extend native 'Error' class", () => { + assert.ok(new SCIMError() instanceof Error, + "Error type class did not extend native 'Error' class"); + }); + + it("should have instance member 'name' with value 'SCIMError'", () => { + assert.strictEqual((new SCIMError())?.name, "SCIMError", + "Error type class did not include instance member 'name' with value 'SCIMError'"); + }); + + it("should have instance member 'status'", () => { + assert.ok("status" in (new SCIMError()), + "Error type class did not include instance member 'status'"); + }); + + it("should have instance member 'scimType'", () => { + assert.ok("scimType" in (new SCIMError()), + "Error type class did not include instance member 'scimType'"); + }); - describe("SCIMMY.Types.Error", () => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new SCIMMY.Types.Error(), - "Error type class did not instantiate without arguments"); - }); - - it("should extend native 'Error' class", () => { - assert.ok(new SCIMMY.Types.Error() instanceof Error, - "Error type class did not extend native 'Error' class"); - }); - - it("should have instance member 'name' with value 'SCIMError'", () => { - assert.strictEqual((new SCIMMY.Types.Error())?.name, "SCIMError", - "Error type class did not include instance member 'name' with value 'SCIMError'"); - }); - - it("should have instance member 'status'", () => { - assert.ok("status" in (new SCIMMY.Types.Error()), - "Error type class did not include instance member 'status'"); - }); - - it("should have instance member 'scimType'", () => { - assert.ok("scimType" in (new SCIMMY.Types.Error()), - "Error type class did not include instance member 'scimType'"); - }); - - it("should have instance member 'message'", () => { - assert.ok("message" in (new SCIMMY.Types.Error()), - "Error type class did not include instance member 'message'"); - }); + it("should have instance member 'message'", () => { + assert.ok("message" in (new SCIMError()), + "Error type class did not include instance member 'message'"); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 5021283..55d7896 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -2,133 +2,128 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Filter} from "#@/lib/types/filter.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./filter.json"), "utf8").then((f) => JSON.parse(f)); -export const FilterSuite = () => { - it("should include static class 'Filter'", () => - assert.ok(!!SCIMMY.Types.Filter, "Static class 'Filter' not defined")); +describe("SCIMMY.Types.Filter", () => { + it("should extend native 'Array' class", () => { + assert.ok(new Filter() instanceof Array, + "Filter type class did not extend native 'Array' class"); + }); - describe("SCIMMY.Types.Filter", () => { + describe("@constructor", () => { it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new SCIMMY.Types.Filter(), + assert.doesNotThrow(() => new Filter(), "Filter type class did not instantiate without arguments"); }); - it("should extend native 'Array' class", () => { - assert.ok(new SCIMMY.Types.Filter() instanceof Array, - "Filter type class did not extend native 'Array' class"); - }); - - describe("#constructor", () => { - it("should expect 'expression' argument to be a non-empty string or collection of objects", () => { - const fixtures = [ - ["number value '1'", 1], - ["boolean value 'false'", false] - ]; - - assert.throws(() => new SCIMMY.Types.Filter(""), - {name: "TypeError", message: "Expected 'expression' parameter string value to not be empty in Filter constructor"}, - "Filter type class did not expect expression to be a non-empty string"); - - for (let [label, value] of fixtures) { - assert.throws(() => new SCIMMY.Types.Filter(value), - {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array in Filter constructor"}, - `Filter type class did not reject 'expression' parameter ${label}`); - } - }); - - it("should expect expression to a be well formed SCIM filter string", () => { - assert.throws(() => new SCIMMY.Types.Filter("id -pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", message: "Unexpected token '-pr' in filter"}, - "Filter type class did not reject 'expression' parameter value 'id -pr' that was not well formed"); - }); + it("should expect 'expression' argument to be a non-empty string or collection of objects", () => { + const fixtures = [ + ["number value '1'", 1], + ["boolean value 'false'", false] + ]; - it("should expect all grouping operators to be opened and closed in filter string expression", () => { - assert.throws(() => new SCIMMY.Types.Filter("[id pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Missing closing ']' token in filter '[id pr'"}, - "Filter type class did not reject 'expression' parameter with unmatched opening '[' bracket"); - assert.throws(() => new SCIMMY.Types.Filter("id pr]"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Unexpected token ']' in filter"}, - `Filter type class did not reject 'expression' parameter with unmatched closing ']' bracket`); - assert.throws(() => new SCIMMY.Types.Filter("(id pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Missing closing ')' token in filter '(id pr'"}, - "Filter type class did not reject 'expression' parameter with unmatched opening '(' bracket"); - assert.throws(() => new SCIMMY.Types.Filter("id pr)"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Unexpected token ')' in filter"}, - `Filter type class did not reject 'expression' parameter with unmatched closing ')' bracket`); - }); + assert.throws(() => new Filter(""), + {name: "TypeError", message: "Expected 'expression' parameter string value to not be empty in Filter constructor"}, + "Filter type class did not expect expression to be a non-empty string"); - it("should parse simple expressions without logical or grouping operators", async () => { - const {parse: {simple: suite}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, - `Filter type class failed to parse simple expression '${fixture.source}'`); - } - }); - - it("should parse expressions with logical operators", async () => { - const {parse: {logical: suite}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, - `Filter type class failed to parse expression '${fixture.source}' with logical operators`); - } - }); + for (let [label, value] of fixtures) { + assert.throws(() => new Filter(value), + {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array in Filter constructor"}, + `Filter type class did not reject 'expression' parameter ${label}`); + } + }); + + it("should expect expression to a be well formed SCIM filter string", () => { + assert.throws(() => new Filter("id -pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", message: "Unexpected token '-pr' in filter"}, + "Filter type class did not reject 'expression' parameter value 'id -pr' that was not well formed"); + }); + + it("should expect all grouping operators to be opened and closed in filter string expression", () => { + assert.throws(() => new Filter("[id pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Missing closing ']' token in filter '[id pr'"}, + "Filter type class did not reject 'expression' parameter with unmatched opening '[' bracket"); + assert.throws(() => new Filter("id pr]"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Unexpected token ']' in filter"}, + `Filter type class did not reject 'expression' parameter with unmatched closing ']' bracket`); + assert.throws(() => new Filter("(id pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Missing closing ')' token in filter '(id pr'"}, + "Filter type class did not reject 'expression' parameter with unmatched opening '(' bracket"); + assert.throws(() => new Filter("id pr)"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Unexpected token ')' in filter"}, + `Filter type class did not reject 'expression' parameter with unmatched closing ')' bracket`); + }); + + it("should parse simple expressions without logical or grouping operators", async () => { + const {parse: {simple: suite}} = await fixtures; - it("should parse expressions with grouping operators", async () => { - const {parse: {grouping: suite}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, - `Filter type class failed to parse expression '${fixture.source}' with grouping operators`); - } - }); + for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse simple expression '${fixture.source}'`); + } + }); + + it("should parse expressions with logical operators", async () => { + const {parse: {logical: suite}} = await fixtures; - it("should parse complex expressions with a mix of logical and grouping operators", async () => { - const {parse: {complex: suite}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, - `Filter type class failed to parse complex expression '${fixture.source}'`); - } - }); + for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse expression '${fixture.source}' with logical operators`); + } }); - describe(".match()", () => { - it("should have instance method 'match'", () => { - assert.ok(typeof (new SCIMMY.Types.Filter()).match === "function", - "Instance method 'match' not defined"); - }); + it("should parse expressions with grouping operators", async () => { + const {parse: {grouping: suite}} = await fixtures; - const targets = [ - ["comparators", "handle matches for known comparison expressions"], - ["nesting", "handle matching of nested attributes"], - ["cases", "match attribute names in a case-insensitive manner"], - ["negations", "handle matches with negation expressions"], - ["numbers", "correctly compare numeric attribute values"], - ["dates", "correctly compare ISO 8601 datetime string attribute values"], - ["logicalAnd", "match values against all expressions in a group of logical 'and' expressions for a single attribute"], - ["logicalOr", "match values against any one expression in a group of logical 'or' expressions"] - ]; + for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse expression '${fixture.source}' with grouping operators`); + } + }); + + it("should parse complex expressions with a mix of logical and grouping operators", async () => { + const {parse: {complex: suite}} = await fixtures; - for (let [key, label] of targets) { - it(`should ${label}`, async () => { - const {match: {source, targets: {[key]: suite}}} = await fixtures; - - for (let fixture of suite) { - assert.deepStrictEqual(new SCIMMY.Types.Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, - `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1} with expression '${JSON.stringify(fixture.expression)}'`); - } - }); + for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse complex expression '${fixture.source}'`); } }); }); -}; \ No newline at end of file + + describe(".match()", () => { + it("should have instance method 'match'", () => { + assert.ok(typeof (new Filter()).match === "function", + "Instance method 'match' not defined"); + }); + + const targets = [ + ["comparators", "handle matches for known comparison expressions"], + ["nesting", "handle matching of nested attributes"], + ["cases", "match attribute names in a case-insensitive manner"], + ["negations", "handle matches with negation expressions"], + ["numbers", "correctly compare numeric attribute values"], + ["dates", "correctly compare ISO 8601 datetime string attribute values"], + ["logicalAnd", "match values against all expressions in a group of logical 'and' expressions for a single attribute"], + ["logicalOr", "match values against any one expression in a group of logical 'or' expressions"] + ]; + + for (let [key, label] of targets) { + it(`should ${label}`, async () => { + const {match: {source, targets: {[key]: suite}}} = await fixtures; + + for (let fixture of suite) { + assert.deepStrictEqual(new Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, + `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1} with expression '${JSON.stringify(fixture.expression)}'`); + } + }); + } + }); +}); \ No newline at end of file diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 2ffad95..585095d 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,5 +1,5 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Resource} from "#@/lib/types/resource.js"; import {createSchemaClass} from "./schema.js"; // Default values to use when creating a resource class in tests @@ -10,10 +10,10 @@ const extension = ["Extension", "urn:ietf:params:scim:schemas:Extension", "An Ex * Create a class that extends SCIMMY.Types.Resource, for use in tests * @param {String} name - the name of the Resource to create a class for * @param {*[]} params - arguments to pass through to the Schema class - * @returns {typeof SCIMMY.Types.Resource} a class that extends SCIMMY.Types.Resource for use in tests + * @returns {typeof Resource} a class that extends SCIMMY.Types.Resource for use in tests */ export const createResourceClass = (name, ...params) => ( - class Test extends SCIMMY.Types.Resource { + class Test extends Resource { static #endpoint = `/${name}` static get endpoint() { return Test.#endpoint; } static #schema = createSchemaClass(name, ...params); @@ -21,134 +21,129 @@ export const createResourceClass = (name, ...params) => ( } ); -export const ResourceSuite = () => { - it("should include static class 'Resource'", () => - assert.ok(!!SCIMMY.Types.Resource, "Static class 'Resource' not defined")); +describe("SCIMMY.Types.Resource", () => { + it("should have abstract static member 'endpoint'", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, "endpoint").get === "function", + "Abstract static member 'endpoint' not defined"); + assert.throws(() => Resource.endpoint, + {name: "TypeError", message: "Method 'get' for property 'endpoint' not implemented by resource 'Resource'"}, + "Static member 'endpoint' not abstract"); + }); - describe("SCIMMY.Types.Resource", () => { - it("should have abstract static member 'endpoint'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(SCIMMY.Types.Resource, "endpoint").get === "function", - "Abstract static member 'endpoint' not defined"); - assert.throws(() => SCIMMY.Types.Resource.endpoint, - {name: "TypeError", message: "Method 'get' for property 'endpoint' not implemented by resource 'Resource'"}, - "Static member 'endpoint' not abstract"); - }); - - it("should have abstract static member 'schema'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(SCIMMY.Types.Resource, "schema").get === "function", - "Abstract static member 'schema' not defined"); - assert.throws(() => SCIMMY.Types.Resource.schema, - {name: "TypeError", message: "Method 'get' for property 'schema' not implemented by resource 'Resource'"}, - "Static member 'schema' not abstract"); - }); - - it("should have abstract static method 'basepath'", () => { - assert.ok(typeof SCIMMY.Types.Resource.basepath === "function", - "Abstract static method 'basepath' not defined"); - assert.throws(() => SCIMMY.Types.Resource.basepath(), - {name: "TypeError", message: "Method 'basepath' not implemented by resource 'Resource'"}, - "Static method 'basepath' not abstract"); - }); - - it("should have abstract static method 'ingress'", () => { - assert.ok(typeof SCIMMY.Types.Resource.ingress === "function", - "Abstract static method 'ingress' not defined"); - assert.throws(() => SCIMMY.Types.Resource.ingress(), - {name: "TypeError", message: "Method 'ingress' not implemented by resource 'Resource'"}, - "Static method 'ingress' not abstract"); - }); - - it("should have abstract static method 'egress'", () => { - assert.ok(typeof SCIMMY.Types.Resource.egress === "function", - "Abstract static method 'egress' not defined"); - assert.throws(() => SCIMMY.Types.Resource.egress(), - {name: "TypeError", message: "Method 'egress' not implemented by resource 'Resource'"}, - "Static method 'egress' not abstract"); - }); - - it("should have abstract static method 'degress'", () => { - assert.ok(typeof SCIMMY.Types.Resource.degress === "function", - "Abstract static method 'degress' not defined"); - assert.throws(() => SCIMMY.Types.Resource.degress(), - {name: "TypeError", message: "Method 'degress' not implemented by resource 'Resource'"}, - "Static method 'degress' not abstract"); - }); - - it("should have abstract instance method 'read'", () => { - assert.ok(typeof (new SCIMMY.Types.Resource()).read === "function", - "Abstract instance method 'read' not defined"); - assert.throws(() => new SCIMMY.Types.Resource().read(), - {name: "TypeError", message: "Method 'read' not implemented by resource 'Resource'"}, - "Instance method 'read' not abstract"); - }); - - it("should have abstract instance method 'write'", () => { - assert.ok(typeof (new SCIMMY.Types.Resource()).write === "function", - "Abstract instance method 'write' not defined"); - assert.throws(() => new SCIMMY.Types.Resource().write(), - {name: "TypeError", message: "Method 'write' not implemented by resource 'Resource'"}, - "Instance method 'write' not abstract"); + it("should have abstract static member 'schema'", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, "schema").get === "function", + "Abstract static member 'schema' not defined"); + assert.throws(() => Resource.schema, + {name: "TypeError", message: "Method 'get' for property 'schema' not implemented by resource 'Resource'"}, + "Static member 'schema' not abstract"); + }); + + it("should have abstract static method 'basepath'", () => { + assert.ok(typeof Resource.basepath === "function", + "Abstract static method 'basepath' not defined"); + assert.throws(() => Resource.basepath(), + {name: "TypeError", message: "Method 'basepath' not implemented by resource 'Resource'"}, + "Static method 'basepath' not abstract"); + }); + + it("should have abstract static method 'ingress'", () => { + assert.ok(typeof Resource.ingress === "function", + "Abstract static method 'ingress' not defined"); + assert.throws(() => Resource.ingress(), + {name: "TypeError", message: "Method 'ingress' not implemented by resource 'Resource'"}, + "Static method 'ingress' not abstract"); + }); + + it("should have abstract static method 'egress'", () => { + assert.ok(typeof Resource.egress === "function", + "Abstract static method 'egress' not defined"); + assert.throws(() => Resource.egress(), + {name: "TypeError", message: "Method 'egress' not implemented by resource 'Resource'"}, + "Static method 'egress' not abstract"); + }); + + it("should have abstract static method 'degress'", () => { + assert.ok(typeof Resource.degress === "function", + "Abstract static method 'degress' not defined"); + assert.throws(() => Resource.degress(), + {name: "TypeError", message: "Method 'degress' not implemented by resource 'Resource'"}, + "Static method 'degress' not abstract"); + }); + + it("should have abstract instance method 'read'", () => { + assert.ok(typeof (new Resource()).read === "function", + "Abstract instance method 'read' not defined"); + assert.throws(() => new Resource().read(), + {name: "TypeError", message: "Method 'read' not implemented by resource 'Resource'"}, + "Instance method 'read' not abstract"); + }); + + it("should have abstract instance method 'write'", () => { + assert.ok(typeof (new Resource()).write === "function", + "Abstract instance method 'write' not defined"); + assert.throws(() => new Resource().write(), + {name: "TypeError", message: "Method 'write' not implemented by resource 'Resource'"}, + "Instance method 'write' not abstract"); + }); + + it("should have abstract instance method 'patch'", () => { + assert.ok(typeof (new Resource()).patch === "function", + "Abstract instance method 'patch' not defined"); + assert.throws(() => new Resource().patch(), + {name: "TypeError", message: "Method 'patch' not implemented by resource 'Resource'"}, + "Instance method 'patch' not abstract"); + }); + + it("should have abstract instance method 'dispose'", () => { + assert.ok(typeof (new Resource()).dispose === "function", + "Abstract instance method 'dispose' not defined"); + assert.throws(() => new Resource().dispose(), + {name: "TypeError", message: "Method 'dispose' not implemented by resource 'Resource'"}, + "Instance method 'dispose' not abstract"); + }); + + describe(".extend()", () => { + it("should have static method 'extend'", () => { + assert.ok(typeof Resource.extend === "function", + "Static method 'extend' not defined"); }); - - it("should have abstract instance method 'patch'", () => { - assert.ok(typeof (new SCIMMY.Types.Resource()).patch === "function", - "Abstract instance method 'patch' not defined"); - assert.throws(() => new SCIMMY.Types.Resource().patch(), - {name: "TypeError", message: "Method 'patch' not implemented by resource 'Resource'"}, - "Instance method 'patch' not abstract"); + }); + + describe(".describe()", () => { + it("should have static method 'describe'", () => { + assert.ok(typeof Resource.describe === "function", + "Static method 'describe' not defined"); }); - it("should have abstract instance method 'dispose'", () => { - assert.ok(typeof (new SCIMMY.Types.Resource()).dispose === "function", - "Abstract instance method 'dispose' not defined"); - assert.throws(() => new SCIMMY.Types.Resource().dispose(), - {name: "TypeError", message: "Method 'dispose' not implemented by resource 'Resource'"}, - "Instance method 'dispose' not abstract"); - }); + const TestResource = createResourceClass(...Object.values(params)); + const properties = [ + ["name"], ["description"], ["id", "name"], ["schema", "id"], + ["endpoint", "name", `/${params.name}`, ", with leading forward-slash"] + ]; - describe(".extend()", () => { - it("should have static method 'extend'", () => { - assert.ok(typeof SCIMMY.Types.Resource.extend === "function", - "Static method 'extend' not defined"); + for (let [prop, target = prop, expected = params[target], suffix = ""] of properties) { + it(`should expect '${prop}' property of description to equal '${target}' property of resource's schema definition${suffix}`, () => { + assert.strictEqual(TestResource.describe()[prop], expected, + `Resource 'describe' method returned '${prop}' property with unexpected value`); }); + } + + it("should expect 'schemaExtensions' property to be excluded in description when resource is not extended", () => { + assert.strictEqual(TestResource.describe().schemaExtensions, undefined, + "Resource 'describe' method unexpectedly included 'schemaExtensions' property in description"); }); - describe(".describe()", () => { - it("should have static method 'describe'", () => { - assert.ok(typeof SCIMMY.Types.Resource.describe === "function", - "Static method 'describe' not defined"); - }); - - const TestResource = createResourceClass(...Object.values(params)); - const properties = [ - ["name"], ["description"], ["id", "name"], ["schema", "id"], - ["endpoint", "name", `/${params.name}`, ", with leading forward-slash"] - ]; - - for (let [prop, target = prop, expected = params[target], suffix = ""] of properties) { - it(`should expect '${prop}' property of description to equal '${target}' property of resource's schema definition${suffix}`, () => { - assert.strictEqual(TestResource.describe()[prop], expected, - `Resource 'describe' method returned '${prop}' property with unexpected value`); - }); + it("should expect 'schemaExtensions' property to be included in description when resource is extended", function () { + try { + TestResource.extend(createSchemaClass(...extension)); + } catch { + this.skip(); } - it("should expect 'schemaExtensions' property to be excluded in description when resource is not extended", () => { - assert.strictEqual(TestResource.describe().schemaExtensions, undefined, - "Resource 'describe' method unexpectedly included 'schemaExtensions' property in description"); - }); - - it("should expect 'schemaExtensions' property to be included in description when resource is extended", function () { - try { - TestResource.extend(createSchemaClass(...extension)); - } catch { - this.skip(); - } - - assert.ok(!!TestResource.describe().schemaExtensions, - "Resource 'describe' method did not include 'schemaExtensions' property in description"); - assert.deepStrictEqual(TestResource.describe().schemaExtensions, [{schema: "urn:ietf:params:scim:schemas:Extension", required: false}], - "Resource 'describe' method included 'schemaExtensions' property with unexpected value in description"); - }); + assert.ok(!!TestResource.describe().schemaExtensions, + "Resource 'describe' method did not include 'schemaExtensions' property in description"); + assert.deepStrictEqual(TestResource.describe().schemaExtensions, [{schema: "urn:ietf:params:scim:schemas:Extension", required: false}], + "Resource 'describe' method included 'schemaExtensions' property with unexpected value in description"); }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index b6da5be..bff2cd4 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,43 +1,39 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Schema} from "#@/lib/types/schema.js"; +import {SchemaDefinition} from "#@/lib/types/definition.js"; /** * Create a class that extends SCIMMY.Types.Schema, for use in tests * @param {*[]} params - arguments to pass through to the SchemaDefinition instance - * @returns {typeof SCIMMY.Types.Schema} a class that extends SCIMMY.Types.Schema for use in tests + * @returns {typeof Schema} a class that extends SCIMMY.Types.Schema for use in tests */ export const createSchemaClass = (...params) => ( - class Test extends SCIMMY.Types.Schema { - static #definition = new SCIMMY.Types.SchemaDefinition(...params); + class Test extends Schema { + static #definition = new SchemaDefinition(...params); static get definition() { return Test.#definition; } } ); -export const SchemaSuite = () => { - it("should include static class 'Schema'", () => - assert.ok(!!SCIMMY.Types.Schema, "Static class 'Schema' not defined")); +describe("SCIMMY.Types.Schema", () => { + it("should have abstract static member 'definition'", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "definition").get === "function", + "Abstract static member 'definition' not defined"); + assert.throws(() => Schema.definition, + {name: "TypeError", message: "Method 'get' for property 'definition' must be implemented by subclass"}, + "Static member 'definition' not abstract"); + }); - describe("SCIMMY.Types.Schema", () => { - it("should have abstract static member 'definition'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(SCIMMY.Types.Schema, "definition").get === "function", - "Abstract static member 'definition' not defined"); - assert.throws(() => SCIMMY.Types.Schema.definition, - {name: "TypeError", message: "Method 'get' for property 'definition' must be implemented by subclass"}, - "Static member 'definition' not abstract"); - }); - - describe(".extend()", () => { - it("should have static method 'extend'", () => { - assert.ok(typeof SCIMMY.Types.Schema.extend === "function", - "Static method 'extend' not defined"); - }); + describe(".extend()", () => { + it("should have static method 'extend'", () => { + assert.ok(typeof Schema.extend === "function", + "Static method 'extend' not defined"); }); - - describe(".truncate()", () => { - it("should have static method 'truncate'", () => { - assert.ok(typeof SCIMMY.Types.Schema.truncate === "function", - "Static method 'truncate' not defined"); - }); + }); + + describe(".truncate()", () => { + it("should have static method 'truncate'", () => { + assert.ok(typeof Schema.truncate === "function", + "Static method 'truncate' not defined"); }); }); -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/scimmy.js b/test/scimmy.js index 66e986d..f2e0366 100644 --- a/test/scimmy.js +++ b/test/scimmy.js @@ -1,13 +1,29 @@ -import {ConfigSuite} from "./lib/config.js"; -import {TypesSuite} from "./lib/types.js"; -import {MessagesSuite} from "./lib/messages.js"; -import {SchemasSuite} from "./lib/schemas.js"; -import {ResourcesSuite} from "./lib/resources.js"; +import assert from "assert"; +import SCIMMY from "#@/scimmy.js"; describe("SCIMMY", () => { - ConfigSuite(); - TypesSuite(); - MessagesSuite(); - SchemasSuite(); - ResourcesSuite(); + it("should include static class 'Config'", () => { + assert.ok(!!SCIMMY.Config, + "Static class 'Config' not defined"); + }); + + it("should include static class 'Types'", () => { + assert.ok(!!SCIMMY.Types, + "Static class 'Types' not defined"); + }); + + it("should include static class 'Messages'", () => { + assert.ok(!!SCIMMY.Messages, + "Static class 'Messages' not defined"); + }); + + it("should include static class 'Schemas'", () => { + assert.ok(!!SCIMMY.Schemas, + "Static class 'Schemas' not defined"); + }); + + it("should include static class 'Resources'", () => { + assert.ok(!!SCIMMY.Resources, + "Static class 'Resources' not defined"); + }); }); \ No newline at end of file From f8f43950f60b7e6fc4974628f778ddade9d7b255 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 15:43:35 +1000 Subject: [PATCH 60/93] Fix(SCIMMY.Resources.{User,Group}): rethrow errors in 'dispose' --- src/lib/resources/group.js | 12 ++++++++++-- src/lib/resources/user.js | 12 ++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/resources/group.js b/src/lib/resources/group.js index 88e26a3..d52ef9f 100644 --- a/src/lib/resources/group.js +++ b/src/lib/resources/group.js @@ -150,7 +150,15 @@ export class Group extends Types.Resource { * await (new SCIMMY.Resources.Group("1234")).dispose(); */ async dispose() { - if (!!this.id) await Group.#degress(this); - else throw new Types.Error(404, null, "DELETE operation must target a specific resource"); + if (!this.id) + throw new Types.Error(404, null, "DELETE operation must target a specific resource"); + + try { + await Group.#degress(this); + } catch (ex) { + if (ex instanceof Types.Error) throw ex; + else if (ex instanceof TypeError) throw new Types.Error(500, null, ex.message); + else throw new Types.Error(404, null, `Resource ${this.id} not found`); + } } } \ No newline at end of file diff --git a/src/lib/resources/user.js b/src/lib/resources/user.js index 6b721c6..840ee46 100644 --- a/src/lib/resources/user.js +++ b/src/lib/resources/user.js @@ -150,7 +150,15 @@ export class User extends Types.Resource { * await (new SCIMMY.Resources.User("1234")).dispose(); */ async dispose() { - if (!!this.id) await User.#degress(this); - else throw new Types.Error(404, null, "DELETE operation must target a specific resource"); + if (!this.id) + throw new Types.Error(404, null, "DELETE operation must target a specific resource"); + + try { + await User.#degress(this); + } catch (ex) { + if (ex instanceof Types.Error) throw ex; + else if (ex instanceof TypeError) throw new Types.Error(500, null, ex.message); + else throw new Types.Error(404, null, `Resource ${this.id} not found`); + } } } \ No newline at end of file From 681b6ff04ad85efaa586ede377e5163559205c34 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 17:39:06 +1000 Subject: [PATCH 61/93] Tests(SCIMMY.Resources.*): fix import ordering --- test/lib/resources/group.js | 26 +++++++++++------------ test/lib/resources/resourcetype.js | 33 +++++++++++++++--------------- test/lib/resources/schema.js | 33 +++++++++++++++--------------- test/lib/resources/spconfig.js | 26 +++++++++++------------ test/lib/resources/user.js | 26 +++++++++++------------ 5 files changed, 71 insertions(+), 73 deletions(-) diff --git a/test/lib/resources/group.js b/test/lib/resources/group.js index f1da261..4fa8bd2 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -1,23 +1,23 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {Group} from "#@/lib/resources/group.js"; import {ResourcesHooks} from "../resources.js"; +import {Group} from "#@/lib/resources/group.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); describe("SCIMMY.Resources.Group", () => { - context(".endpoint", ResourcesHooks.endpoint(Group)); - context(".schema", ResourcesHooks.schema(Group)); - context(".basepath()", ResourcesHooks.basepath(Group)); - context(".extend()", ResourcesHooks.extend(Group, false)); - context(".ingress()", ResourcesHooks.ingress(Group, fixtures)); - context(".egress()", ResourcesHooks.egress(Group, fixtures)); - context(".degress()", ResourcesHooks.degress(Group, fixtures)); - context("@constructor", ResourcesHooks.construct(Group)); - context("#read()", ResourcesHooks.read(Group, fixtures)); - context("#write()", ResourcesHooks.write(Group, fixtures)); - context("#patch()", ResourcesHooks.patch(Group, fixtures)); - context("#dispose()", ResourcesHooks.dispose(Group, fixtures)); + describe(".endpoint", ResourcesHooks.endpoint(Group)); + describe(".schema", ResourcesHooks.schema(Group)); + describe(".basepath()", ResourcesHooks.basepath(Group)); + describe(".extend()", ResourcesHooks.extend(Group, false)); + describe(".ingress()", ResourcesHooks.ingress(Group, fixtures)); + describe(".egress()", ResourcesHooks.egress(Group, fixtures)); + describe(".degress()", ResourcesHooks.degress(Group, fixtures)); + describe("@constructor", ResourcesHooks.construct(Group)); + describe("#read()", ResourcesHooks.read(Group, fixtures)); + describe("#write()", ResourcesHooks.write(Group, fixtures)); + describe("#patch()", ResourcesHooks.patch(Group, fixtures)); + describe("#dispose()", ResourcesHooks.dispose(Group, fixtures)); }); \ No newline at end of file diff --git a/test/lib/resources/resourcetype.js b/test/lib/resources/resourcetype.js index 39c0529..5d61ef4 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -5,8 +5,8 @@ import sinon from "sinon"; import * as Resources from "#@/lib/resources.js"; import {User} from "#@/lib/resources/user.js"; import {Group} from "#@/lib/resources/group.js"; -import {ResourceType} from "#@/lib/resources/resourcetype.js"; import {ResourcesHooks} from "../resources.js"; +import {ResourceType} from "#@/lib/resources/resourcetype.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); @@ -16,22 +16,21 @@ describe("SCIMMY.Resources.ResourceType", () => { after(() => sandbox.restore()); before(() => { - const declared = sandbox.stub(Resources.default, "declared"); - - declared.returns([User, Group]); - declared.withArgs(User.schema.definition.name).returns(User); + sandbox.stub(Resources.default, "declared") + .returns([User, Group]) + .withArgs(User.schema.definition.name).returns(User); }); - context(".endpoint", ResourcesHooks.endpoint(ResourceType)); - context(".schema", ResourcesHooks.schema(ResourceType, false)); - context(".basepath()", ResourcesHooks.basepath(ResourceType)); - context(".extend()", ResourcesHooks.extend(ResourceType, true)); - context(".ingress()", ResourcesHooks.ingress(ResourceType, false)); - context(".egress()", ResourcesHooks.egress(ResourceType, false)); - context(".degress()", ResourcesHooks.degress(ResourceType, false)); - context("@constructor", ResourcesHooks.construct(ResourceType, false)); - context("#read()", ResourcesHooks.read(ResourceType, fixtures)); - context("#write()", ResourcesHooks.write(ResourceType, false)); - context("#patch()", ResourcesHooks.patch(ResourceType, false)); - context("#dispose()", ResourcesHooks.dispose(ResourceType, false)); + describe(".endpoint", ResourcesHooks.endpoint(ResourceType)); + describe(".schema", ResourcesHooks.schema(ResourceType, false)); + describe(".basepath()", ResourcesHooks.basepath(ResourceType)); + describe(".extend()", ResourcesHooks.extend(ResourceType, true)); + describe(".ingress()", ResourcesHooks.ingress(ResourceType, false)); + describe(".egress()", ResourcesHooks.egress(ResourceType, false)); + describe(".degress()", ResourcesHooks.degress(ResourceType, false)); + describe("@constructor", ResourcesHooks.construct(ResourceType, false)); + describe("#read()", ResourcesHooks.read(ResourceType, fixtures)); + describe("#write()", ResourcesHooks.write(ResourceType, false)); + describe("#patch()", ResourcesHooks.patch(ResourceType, false)); + describe("#dispose()", ResourcesHooks.dispose(ResourceType, false)); }); \ No newline at end of file diff --git a/test/lib/resources/schema.js b/test/lib/resources/schema.js index 89592bb..a71bca2 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -5,8 +5,8 @@ import sinon from "sinon"; import * as Schemas from "#@/lib/schemas.js"; import {User} from "#@/lib/schemas/user.js"; import {Group} from "#@/lib/schemas/group.js"; -import {Schema} from "#@/lib/resources/schema.js"; import {ResourcesHooks} from "../resources.js"; +import {Schema} from "#@/lib/resources/schema.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); @@ -16,22 +16,21 @@ describe("SCIMMY.Resources.Schema", () => { after(() => sandbox.restore()); before(() => { - const declared = sandbox.stub(Schemas.default, "declared"); - - declared.returns([User.definition, Group.definition]); - declared.withArgs(User.definition.id).returns(User.definition); + sandbox.stub(Schemas.default, "declared") + .returns([User.definition, Group.definition]) + .withArgs(User.definition.id).returns(User.definition); }); - context(".endpoint", ResourcesHooks.endpoint(Schema)); - context(".schema", ResourcesHooks.schema(Schema, false)); - context(".basepath()", ResourcesHooks.basepath(Schema)); - context(".extend()", ResourcesHooks.extend(Schema, true)); - context(".ingress()", ResourcesHooks.ingress(Schema, false)); - context(".egress()", ResourcesHooks.egress(Schema, false)); - context(".degress()", ResourcesHooks.degress(Schema, false)); - context("@constructor", ResourcesHooks.construct(Schema, false)); - context("#read()", ResourcesHooks.read(Schema, fixtures)); - context("#write()", ResourcesHooks.write(Schema, false)); - context("#patch()", ResourcesHooks.patch(Schema, false)); - context("#dispose()", ResourcesHooks.dispose(Schema, false)); + describe(".endpoint", ResourcesHooks.endpoint(Schema)); + describe(".schema", ResourcesHooks.schema(Schema, false)); + describe(".basepath()", ResourcesHooks.basepath(Schema)); + describe(".extend()", ResourcesHooks.extend(Schema, true)); + describe(".ingress()", ResourcesHooks.ingress(Schema, false)); + describe(".egress()", ResourcesHooks.egress(Schema, false)); + describe(".degress()", ResourcesHooks.degress(Schema, false)); + describe("@constructor", ResourcesHooks.construct(Schema, false)); + describe("#read()", ResourcesHooks.read(Schema, fixtures)); + describe("#write()", ResourcesHooks.write(Schema, false)); + describe("#patch()", ResourcesHooks.patch(Schema, false)); + describe("#dispose()", ResourcesHooks.dispose(Schema, false)); }); \ No newline at end of file diff --git a/test/lib/resources/spconfig.js b/test/lib/resources/spconfig.js index 7798b5c..29cd988 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -3,8 +3,8 @@ import path from "path"; import url from "url"; import sinon from "sinon"; import * as Config from "#@/lib/config.js"; -import {ServiceProviderConfig} from "#@/lib/resources/spconfig.js"; import {ResourcesHooks} from "../resources.js"; +import {ServiceProviderConfig} from "#@/lib/resources/spconfig.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); @@ -19,16 +19,16 @@ describe("SCIMMY.Resources.ServiceProviderConfig", () => { patch: {supported: false}, changePassword: {supported: false}, etag: {supported: false} })); - context(".endpoint", ResourcesHooks.endpoint(ServiceProviderConfig)); - context(".schema", ResourcesHooks.schema(ServiceProviderConfig, false)); - context(".basepath()", ResourcesHooks.basepath(ServiceProviderConfig)); - context(".extend()", ResourcesHooks.extend(ServiceProviderConfig, true)); - context(".ingress()", ResourcesHooks.ingress(ServiceProviderConfig, false)); - context(".egress()", ResourcesHooks.egress(ServiceProviderConfig, false)); - context(".degress()", ResourcesHooks.degress(ServiceProviderConfig, false)); - context("@constructor", ResourcesHooks.construct(ServiceProviderConfig, false)); - context("#read()", ResourcesHooks.read(ServiceProviderConfig, fixtures, false)); - context("#write()", ResourcesHooks.write(ServiceProviderConfig, false)); - context("#patch()", ResourcesHooks.patch(ServiceProviderConfig, false)); - context("#dispose()", ResourcesHooks.dispose(ServiceProviderConfig, false)); + describe(".endpoint", ResourcesHooks.endpoint(ServiceProviderConfig)); + describe(".schema", ResourcesHooks.schema(ServiceProviderConfig, false)); + describe(".basepath()", ResourcesHooks.basepath(ServiceProviderConfig)); + describe(".extend()", ResourcesHooks.extend(ServiceProviderConfig, true)); + describe(".ingress()", ResourcesHooks.ingress(ServiceProviderConfig, false)); + describe(".egress()", ResourcesHooks.egress(ServiceProviderConfig, false)); + describe(".degress()", ResourcesHooks.degress(ServiceProviderConfig, false)); + describe("@constructor", ResourcesHooks.construct(ServiceProviderConfig, false)); + describe("#read()", ResourcesHooks.read(ServiceProviderConfig, fixtures, false)); + describe("#write()", ResourcesHooks.write(ServiceProviderConfig, false)); + describe("#patch()", ResourcesHooks.patch(ServiceProviderConfig, false)); + describe("#dispose()", ResourcesHooks.dispose(ServiceProviderConfig, false)); }); \ No newline at end of file diff --git a/test/lib/resources/user.js b/test/lib/resources/user.js index c32b1da..42ca67c 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -1,23 +1,23 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {User} from "#@/lib/resources/user.js"; import {ResourcesHooks} from "../resources.js"; +import {User} from "#@/lib/resources/user.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); describe("SCIMMY.Resources.User", () => { - context(".endpoint", ResourcesHooks.endpoint(User)); - context(".schema", ResourcesHooks.schema(User)); - context(".basepath()", ResourcesHooks.basepath(User)); - context(".extend()", ResourcesHooks.extend(User, false)); - context(".ingress()", ResourcesHooks.ingress(User, fixtures)); - context(".egress()", ResourcesHooks.egress(User, fixtures)); - context(".degress()", ResourcesHooks.degress(User, fixtures)); - context("@constructor", ResourcesHooks.construct(User)); - context("#read()", ResourcesHooks.read(User, fixtures)); - context("#write()", ResourcesHooks.write(User, fixtures)); - context("#patch()", ResourcesHooks.patch(User, fixtures)); - context("#dispose()", ResourcesHooks.dispose(User, fixtures)); + describe(".endpoint", ResourcesHooks.endpoint(User)); + describe(".schema", ResourcesHooks.schema(User)); + describe(".basepath()", ResourcesHooks.basepath(User)); + describe(".extend()", ResourcesHooks.extend(User, false)); + describe(".ingress()", ResourcesHooks.ingress(User, fixtures)); + describe(".egress()", ResourcesHooks.egress(User, fixtures)); + describe(".degress()", ResourcesHooks.degress(User, fixtures)); + describe("@constructor", ResourcesHooks.construct(User)); + describe("#read()", ResourcesHooks.read(User, fixtures)); + describe("#write()", ResourcesHooks.write(User, fixtures)); + describe("#patch()", ResourcesHooks.patch(User, fixtures)); + describe("#dispose()", ResourcesHooks.dispose(User, fixtures)); }); \ No newline at end of file From 89bff4c67959019ee3b4dbdae4b26622e92d27f2 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 17:39:30 +1000 Subject: [PATCH 62/93] Tests(SCIMMY.Schemas.*): fix import ordering --- test/lib/schemas/enterpriseuser.js | 2 +- test/lib/schemas/group.js | 2 +- test/lib/schemas/resourcetype.js | 2 +- test/lib/schemas/spconfig.js | 2 +- test/lib/schemas/user.js | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/lib/schemas/enterpriseuser.js b/test/lib/schemas/enterpriseuser.js index 6d93e26..67f9dec 100644 --- a/test/lib/schemas/enterpriseuser.js +++ b/test/lib/schemas/enterpriseuser.js @@ -1,8 +1,8 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {EnterpriseUser} from "#@/lib/schemas/enterpriseuser.js"; import {SchemasHooks} from "../schemas.js"; +import {EnterpriseUser} from "#@/lib/schemas/enterpriseuser.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/group.js b/test/lib/schemas/group.js index a8cb508..db8c20c 100644 --- a/test/lib/schemas/group.js +++ b/test/lib/schemas/group.js @@ -1,8 +1,8 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {Group} from "#@/lib/schemas/group.js"; import {SchemasHooks} from "../schemas.js"; +import {Group} from "#@/lib/schemas/group.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/resourcetype.js b/test/lib/schemas/resourcetype.js index 46ee80f..91209ca 100644 --- a/test/lib/schemas/resourcetype.js +++ b/test/lib/schemas/resourcetype.js @@ -1,8 +1,8 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {ResourceType} from "#@/lib/schemas/resourcetype.js"; import {SchemasHooks} from "../schemas.js"; +import {ResourceType} from "#@/lib/schemas/resourcetype.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/spconfig.js b/test/lib/schemas/spconfig.js index e45c1be..11cda47 100644 --- a/test/lib/schemas/spconfig.js +++ b/test/lib/schemas/spconfig.js @@ -1,8 +1,8 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {ServiceProviderConfig} from "#@/lib/schemas/spconfig.js"; import {SchemasHooks} from "../schemas.js"; +import {ServiceProviderConfig} from "#@/lib/schemas/spconfig.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/user.js b/test/lib/schemas/user.js index 7745fcf..7ade66b 100644 --- a/test/lib/schemas/user.js +++ b/test/lib/schemas/user.js @@ -1,8 +1,8 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {User} from "#@/lib/schemas/user.js"; import {SchemasHooks} from "../schemas.js"; +import {User} from "#@/lib/schemas/user.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); From 8144d9b2a18b0c2fd94282d1ef057f1518f97454 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 17:42:46 +1000 Subject: [PATCH 63/93] Tests(SCIMMY.Resources): add coverage for rethrown errors in read/write/patch/delete methods --- test/lib/resources.js | 151 +++++++++++++++++++++++++++++++++++------- 1 file changed, 128 insertions(+), 23 deletions(-) diff --git a/test/lib/resources.js b/test/lib/resources.js index 77d3f01..a069a7a 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -1,6 +1,7 @@ import assert from "assert"; import sinon from "sinon"; import * as Schemas from "#@/lib/schemas.js"; +import {SCIMError} from "#@/lib/types/error.js"; import SCIMMY from "#@/scimmy.js"; import Resources from "#@/lib/resources.js"; @@ -115,12 +116,17 @@ describe("SCIMMY.Resources", () => { "Static method 'declared' did not return all declared resources when called without arguments"); }); - it("should find declared resource by name when 'config' argument is a string", () => { + it("should return boolean 'false' when called with unexpected arguments", () => { + assert.strictEqual(Resources.declared({}), false, + "Static method 'declared' did not return boolean 'false' when called with unexpected arguments"); + }); + + it("should find declared resource by name when 'resource' argument is a string", () => { assert.deepStrictEqual(Resources.declared("User"), Resources.User, - "Static method 'declared' did not find declared resource 'User' when called with 'config' string value 'User'"); + "Static method 'declared' did not find declared resource 'User' when called with 'resource' string value 'User'"); }); - it("should find declaration status of resource when 'config' argument is a resource instance", () => { + it("should find declaration status of resource when 'resource' argument is a resource instance", () => { assert.ok(Resources.declared(Resources.User), "Static method 'declared' did not find declaration status of declared 'User' resource by instance"); assert.ok(!Resources.declared(Resources.ResourceType), @@ -189,14 +195,27 @@ export const ResourcesHooks = { }); } else { const handler = async (res, instance) => { - const {egress} = await fixtures; - const target = Object.assign( - (!!res.id ? egress.find(f => f.id === res.id) : {id: "5"}), - JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) - ); + const {id} = res ?? {}; - if (!egress.includes(target)) egress.push(target); - return target; + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const target = Object.assign( + egress.find(f => f.id === id) ?? {id: "5"}, + JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) + ); + + if (!egress.includes(target)) { + if (!!id) throw new Error("Not found"); + else egress.push(target); + } + + return target; + } }; it("should be implemented", () => { @@ -221,11 +240,20 @@ export const ResourcesHooks = { }); } else { const handler = async (res) => { - const {egress} = await fixtures; - const target = (!!res.id ? egress.find(f => f.id === res.id) : egress); + const {id} = res ?? {}; - if (!target) throw new Error("Not found"); - else return (Array.isArray(target) ? target : [target]); + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const target = (!!id ? egress.find(f => f.id === id) : egress); + + if (!target) throw new Error("Not found"); + else return (Array.isArray(target) ? target : [target]); + } }; it("should be implemented", () => { @@ -250,11 +278,20 @@ export const ResourcesHooks = { }); } else { const handler = async (res) => { - const {egress} = await fixtures; - const index = egress.indexOf(egress.find(f => f.id === res.id)); + const {id} = res ?? {}; - if (index < 0) throw new SCIMMY.Types.Error(404, null, `Resource ${res.id} not found`); - else egress.splice(index, 1); + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const index = egress.indexOf(egress.find(f => f.id === id)); + + if (index < 0) throw new Error("Not found"); + else egress.splice(index, 1); + } }; it("should be implemented", () => { @@ -279,15 +316,14 @@ export const ResourcesHooks = { }); it("should only set basepath once, then do nothing", () => { - const existing = TargetResource.basepath(); const expected = `/scim${TargetResource.endpoint}`; TargetResource.basepath("/scim"); - assert.ok(TargetResource.basepath() === (existing ?? expected), + assert.ok(TargetResource.basepath() === (expected), "Static method 'basepath' did not set or ignore resource basepath"); TargetResource.basepath("/test"); - assert.ok(TargetResource.basepath() === (existing ?? expected), + assert.ok(TargetResource.basepath() === (expected), "Static method 'basepath' did not do nothing when basepath was already set"); }); }), @@ -463,7 +499,7 @@ export const ResourcesHooks = { } } }); - + it("should call ingress to create new resources when resource instantiated without ID", async () => { const {ingress: source} = await fixtures; const result = await (new TargetResource()).write(source); @@ -482,6 +518,27 @@ export const ResourcesHooks = { assert.deepStrictEqual(actual, expected, "Instance method 'write' did not update existing resource"); }); + + it("should expect a resource with supplied ID to exist", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("10").write(source), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'write' did not expect requested resource to exist"); + }); + + it("should rethrow SCIMErrors", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("SCIMError").write(source), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'write' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("TypeError").write(source), + {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, + "Instance method 'write' did not rethrow TypeError as SCIMError"); + }); } }), patch: (TargetResource, fixtures) => (() => { @@ -492,7 +549,7 @@ export const ResourcesHooks = { "Instance method 'patch' unexpectedly implemented by resource"); }); } else { - it("should implement instance method 'patch'", () => { + it("should be implemented", () => { assert.ok("patch" in (new TargetResource()), "Resource did not implement instance method 'patch'"); assert.ok(typeof (new TargetResource()).patch === "function", @@ -543,6 +600,42 @@ export const ResourcesHooks = { assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, "Instance method 'patch' did not return the full resource when resource was modified"); }); + + it("should expect a resource with supplied ID to exist", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("10").patch(message), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'patch' did not expect requested resource to exist"); + }); + + it("should rethrow SCIMErrors", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("SCIMError").patch(message), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'patch' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("TypeError").patch(message), + {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, + "Instance method 'patch' did not rethrow TypeError as SCIMError"); + }); } }), dispose: (TargetResource, fixtures) => (() => { @@ -580,6 +673,18 @@ export const ResourcesHooks = { {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, "Instance method 'dispose' did not expect requested resource to exist"); }); + + it("should rethrow SCIMErrors", async () => { + await assert.rejects(() => new TargetResource("SCIMError").dispose(), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'dispose' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + await assert.rejects(() => new TargetResource("TypeError").dispose(), + {name: "SCIMError", status: 500, scimType: null, message: "Failing as requested"}, + "Instance method 'dispose' did not rethrow TypeError as SCIMError"); + }); } }) }; \ No newline at end of file From c48ba2325b6691920ae242bb7c4bde1515c4d107 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 17:43:55 +1000 Subject: [PATCH 64/93] Tests(SCIMMY.Resources->.declare()): add coverage for 'config' argument properties --- test/lib/resources.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/test/lib/resources.js b/test/lib/resources.js index a069a7a..eb67332 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -4,10 +4,19 @@ import * as Schemas from "#@/lib/schemas.js"; import {SCIMError} from "#@/lib/types/error.js"; import SCIMMY from "#@/scimmy.js"; import Resources from "#@/lib/resources.js"; +import {createResourceClass} from "./types/resource.js"; describe("SCIMMY.Resources", () => { const sandbox = sinon.createSandbox(); + class Test extends createResourceClass("Test", "urn:ietf:params:scim:schemas:Test") { + static ingress = sandbox.stub(); + static egress = sandbox.stub(); + static degress = sandbox.stub(); + static basepath = sandbox.stub(); + static extend = sandbox.stub(); + } + it("should include static class 'Schema'", () => { assert.ok(!!Resources.Schema, "Static class 'Schema' not defined"); @@ -103,6 +112,27 @@ describe("SCIMMY.Resources", () => { "Static method 'declare' did not declare resource type implementation's schema definition"); } }); + + const properties = [ + ["ingress"], + ["egress"], + ["degress"], + ["basepath", "/scim", "a string"], + ["extensions", [{}], "an array", "extend"] + ]; + + for (let [prop, val = (() => {}), kind = "a function", fn = prop] of properties) { + it(`should call resource's '${fn}' static method if '${prop}' property of 'config' argument is ${kind}`, function () { + try { + Resources.declare(Test, {[prop]: val}); + } catch { + this.skip(); + } + + assert.ok(Test[fn].calledOnce, + `Static method 'declare' did not call resource's '${fn}' static method when '${prop}' property of 'config' argument was ${kind}`); + }); + } }); describe(".declared()", () => { @@ -112,7 +142,7 @@ describe("SCIMMY.Resources", () => { }); it("should return all declared resources when called without arguments", () => { - assert.deepStrictEqual(Resources.declared(), {User: Resources.User, Group: Resources.Group}, + assert.deepStrictEqual(Resources.declared(), {Test, User: Resources.User, Group: Resources.Group}, "Static method 'declared' did not return all declared resources when called without arguments"); }); From afa3810ebb4ff30c0deb2f4a964f3c00fc670d92 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 20:38:05 +1000 Subject: [PATCH 65/93] Tests(SCIMMY.Messages.BulkRequest): isolate and mock Resources --- test/lib/messages/bulkrequest.js | 45 ++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index 6601316..5e96dd7 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -2,7 +2,13 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import sinon from "sinon"; +import * as Resources from "#@/lib/resources.js"; +import {SCIMError} from "#@/lib/types/error.js"; +import {Resource} from "#@/lib/types/resource.js"; +import {Error as ErrorMessage} from "#@/lib/messages/error.js"; +import {User} from "#@/lib/resources/user.js"; +import {Group} from "#@/lib/resources/group.js"; import {BulkRequest} from "#@/lib/messages/bulkrequest.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); @@ -14,7 +20,7 @@ const template = {schemas: [params.id], Operations: [{}, {}]}; * BulkRequest Test Resource Class * Because BulkRequest needs a set of implemented resources to test against */ -class Test extends SCIMMY.Types.Resource { +class Test extends Resource { // Store some helpful things for the mock methods static #lastId = 0; static #instances = []; @@ -44,11 +50,16 @@ class Test extends SCIMMY.Types.Resource { // Mock dispose method that removes from static instances array async dispose() { if (this.id) Test.#instances.splice(Test.#instances.indexOf(Test.#instances.find(i => i.id === this.id)), 1); - else throw new SCIMMY.Types.Error(404, null, "DELETE operation must target a specific resource"); + else throw new SCIMError(404, null, "DELETE operation must target a specific resource"); } } describe("SCIMMY.Messages.BulkRequest", () => { + const sandbox = sinon.createSandbox(); + + after(() => sandbox.restore()); + before(() => sandbox.stub(Resources.default, "declared").returns([User, Group])); + describe("@constructor", () => { it("should not instantiate requests with invalid schemas", () => { assert.throws(() => new BulkRequest({schemas: ["nonsense"]}), @@ -134,7 +145,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'method' attribute to have a value for each operation", async () => { const actual = (await (new BulkRequest({...template, Operations: [{}, {path: "/Test"}, {method: ""}]})).apply())?.Operations; const expected = [{status: "400"}, {status: "400", location: "/Test"}, {status: "400", method: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", `Missing or empty 'method' string in BulkRequest operation #${index+1}`)) }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -151,8 +162,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { for (let [label, value] of fixtures) { const actual = (await (new BulkRequest({...template, Operations: [{method: value}]})).apply())?.Operations; const expected = [{status: "400", method: value, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", "Expected 'method' to be a string in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -163,7 +173,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'method' attribute to be one of POST, PUT, PATCH, or DELETE for each operation", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations; const expected = [{status: "400", method: "a string", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidValue", "Invalid 'method' value 'a string' in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -173,7 +183,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'path' attribute to have a value for each operation", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST"}, {method: "POST", path: ""}]})).apply())?.Operations; const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", `Missing or empty 'path' string in BulkRequest operation #${index+1}`)) }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -190,8 +200,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { for (let [label, value] of fixtures) { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: value}]})).apply())?.Operations; const expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", "Expected 'path' to be a string in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -202,7 +211,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'path' attribute to refer to a valid resource type endpoint", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations; const expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidValue", "Invalid 'path' value '/Test' in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -212,7 +221,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations; const expected = [{status: "404", method: "POST", bulkId: "asdf", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -222,7 +231,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'path' attribute to specify a resource ID if 'method' is not POST", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "PUT", path: "/Test"}, {method: "DELETE", path: "/Test"}]})).apply([Test]))?.Operations; const expected = [{status: "404", method: "PUT", location: "/Test"}, {status: "404", method: "DELETE", location: "/Test"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) + ...new ErrorMessage(new SCIMError(404, null, `${e.method} operation must target a specific resource in BulkRequest operation #${index+1}`)) }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -232,7 +241,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'bulkId' attribute to have a value for each 'POST' operation", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}, {method: "POST", path: "/Test", bulkId: ""}]})).apply([Test]))?.Operations; const expected = [{status: "400", method: "POST"}, {status: "400", method: "POST", bulkId: ""}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", `POST operation missing required 'bulkId' string in BulkRequest operation #${index+1}`)) }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -249,8 +258,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { for (let [label, value] of fixtures) { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations; const expected = [{status: "400", method: "POST", response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidValue", "POST operation expected 'bulkId' to be a string in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -261,7 +269,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { it("should expect 'data' attribute to have a value when 'method' is not DELETE", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test", bulkId: "asdf"}, {method: "PUT", path: "/Test/1"}, {method: "PATCH", path: "/Test/1"}]})).apply([Test]))?.Operations; const expected = [{status: "400", method: "POST", bulkId: "asdf"}, {status: "400", method: "PUT", location: "/Test/1"}, {status: "400", method: "PATCH", location: "/Test/1"}].map((e, index) => ({...e, response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", `Expected 'data' to be a single complex value in BulkRequest operation #${index+1}`)) }})); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, @@ -284,8 +292,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { for (let [label, value] of fixtures) { const actual = (await (new BulkRequest({...template, Operations: [{...op, data: value}]})).apply([Test]))?.Operations; const expected = [{status: "400", method: op.method, ...(op.method === "POST" ? {bulkId: op.bulkId} : {location: op.path}), response: { - ...new SCIMMY.Messages.Error(new SCIMMY.Types.Error( - 400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) + ...new ErrorMessage(new SCIMError(400, "invalidSyntax", "Expected 'data' to be a single complex value in BulkRequest operation #1")) }}]; assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, From 0d47f5d5b737f5a0da191886e2e28bd8417a49fc Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 14 Apr 2023 20:42:06 +1000 Subject: [PATCH 66/93] Tests(SCIMMY.Messages.SearchRequest): isolate and mock Resources --- test/lib/messages/searchrequest.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index a05f9fd..ae5f2ca 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -1,5 +1,9 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import sinon from "sinon"; +import * as Resources from "#@/lib/resources.js"; +import {ListResponse} from "#@/lib/messages/listresponse.js"; +import {User} from "#@/lib/resources/user.js"; +import {Group} from "#@/lib/resources/group.js"; import {SearchRequest} from "#@/lib/messages/searchrequest.js"; const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; @@ -28,7 +32,12 @@ const suites = { }; describe("SCIMMY.Messages.SearchRequest", () => { - describe("#constructor", () => { + const sandbox = sinon.createSandbox(); + + after(() => sandbox.restore()); + before(() => sandbox.stub(Resources.default, "declared").returns([User, Group])); + + describe("@constructor", () => { it("should not require arguments at instantiation", () => { assert.deepStrictEqual({...(new SearchRequest())}, template, "SearchRequest did not instantiate with correct default properties"); @@ -234,7 +243,7 @@ describe("SCIMMY.Messages.SearchRequest", () => { }); it("should return a ListResponse message instance", async () => { - assert.ok(await (new SearchRequest()).apply() instanceof SCIMMY.Messages.ListResponse, + assert.ok(await (new SearchRequest()).apply() instanceof ListResponse, "Instance method 'apply' did not return an instance of ListResponse"); }); }); From 659c6751f9599eb619e4b965cd6d04aee395a155 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Sat, 15 Apr 2023 17:31:18 +1000 Subject: [PATCH 67/93] Tests(SCIMMY.Messages.SearchRequest): add coverage for singular resource type application --- src/lib/messages/searchrequest.js | 14 ++--- test/lib/messages/searchrequest.js | 90 +++++++++++++++++++----------- test/lib/types/resource.js | 7 ++- 3 files changed, 68 insertions(+), 43 deletions(-) diff --git a/src/lib/messages/searchrequest.js b/src/lib/messages/searchrequest.js index 6592415..7a6af6e 100644 --- a/src/lib/messages/searchrequest.js +++ b/src/lib/messages/searchrequest.js @@ -37,14 +37,14 @@ export class SearchRequest { * @property {Number} [count] - maximum number of retrieved resources that should be returned in one operation */ constructor(request) { - let {schemas} = request ?? {}; + const {schemas} = request ?? {}; // Verify the SearchRequest contents are valid if (request !== undefined && (!Array.isArray(schemas) || ((schemas.length === 1 && !schemas.includes(SearchRequest.#id)) || schemas.length > 1))) throw new Types.Error(400, "invalidSyntax", `SearchRequest request body messages must exclusively specify schema as '${SearchRequest.#id}'`); try { - // All seems ok, prepare the SearchRequest + // All seems OK, prepare the SearchRequest this.schemas = [SearchRequest.#id]; this.prepare(request); } catch (ex) { @@ -66,7 +66,7 @@ export class SearchRequest { * @returns {SCIMMY.Messages.SearchRequest} this SearchRequest instance for chaining */ prepare(params = {}) { - let {filter, excludedAttributes = [], attributes = [], sortBy, sortOrder, startIndex, count} = params; + const {filter, excludedAttributes = [], attributes = [], sortBy, sortOrder, startIndex, count} = params; // Make sure filter is a non-empty string, if specified if (filter !== undefined && (typeof filter !== "string" || !filter.trim().length)) @@ -104,7 +104,7 @@ export class SearchRequest { /** * Apply a search request operation, retrieving results from specified resource types - * @param {SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing the search request, defaults to declared resources + * @param {typeof SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing the search request, defaults to declared resources * @returns {SCIMMY.Messages.ListResponse} a ListResponse message with results of the search request */ async apply(resourceTypes = Object.values(Resources.declared())) { @@ -113,7 +113,7 @@ export class SearchRequest { throw new TypeError("Expected 'resourceTypes' parameter to be an array of Resource type classes in 'apply' method of SearchRequest"); // Build the common request template - let request = { + const request = { ...(!!this.filter ? {filter: this.filter} : {}), ...(!!this.excludedAttributes ? {excludedAttributes: this.excludedAttributes.join(",")} : {}), ...(!!this.attributes ? {attributes: this.attributes.join(",")} : {}) @@ -121,13 +121,13 @@ export class SearchRequest { // If only one resource type, just read from it if (resourceTypes.length === 1) { - let [Resource] = resourceTypes; + const [Resource] = resourceTypes; return new Resource({...this, ...request}).read(); } // Otherwise, read from all resources and return collected results else { // Read from, and unwrap results for, supplied resource types - let results = await Promise.all(resourceTypes.map((Resource) => new Resource(request).read())) + const results = await Promise.all(resourceTypes.map((Resource) => new Resource(request).read())) .then((r) => r.map((l) => l.Resources)); // Collect the results in a list response with specified constraints diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index ae5f2ca..acf174c 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -5,9 +5,12 @@ import {ListResponse} from "#@/lib/messages/listresponse.js"; import {User} from "#@/lib/resources/user.js"; import {Group} from "#@/lib/resources/group.js"; import {SearchRequest} from "#@/lib/messages/searchrequest.js"; +import {createResourceClass} from "../types/resource.js"; +// Default values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; const template = {schemas: [params.id]}; +// List of test suites to run validation against const suites = { strings: [ ["empty string value", ""], @@ -70,8 +73,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest({...template, excludedAttributes: ["test"]}), "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); - for (let [label, value] of suites.arrays) { - assert.throws(() => new SearchRequest({...template, excludedAttributes: value}), + for (let [label, excludedAttributes] of suites.arrays) { + assert.throws(() => new SearchRequest({...template, excludedAttributes}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); @@ -79,14 +82,14 @@ describe("SCIMMY.Messages.SearchRequest", () => { }); it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { - assert.doesNotThrow(() => new SearchRequest({...template, excludedAttributes: ["test"]}), - "SearchRequest did not instantiate with valid 'excludedAttributes' property non-empty string array value"); + assert.doesNotThrow(() => new SearchRequest({...template, attributes: ["test"]}), + "SearchRequest did not instantiate with valid 'attributes' property non-empty string array value"); - for (let [label, value] of suites.arrays) { - assert.throws(() => new SearchRequest({...template, excludedAttributes: value}), + for (let [label, attributes] of suites.arrays) { + assert.throws(() => new SearchRequest({...template, attributes}), {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings"}, - `SearchRequest instantiated with invalid 'excludedAttributes' property ${label}`); + message: "Expected 'attributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'attributes' property ${label}`); } }); @@ -94,8 +97,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest({...template, sortBy: "test"}), "SearchRequest did not instantiate with valid 'sortBy' property string value 'test'"); - for (let [label, value] of suites.strings) { - assert.throws(() => new SearchRequest({...template, sortBy: value}), + for (let [label, sortBy] of suites.strings) { + assert.throws(() => new SearchRequest({...template, sortBy}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Expected 'sortBy' parameter to be a non-empty string"}, `SearchRequest instantiated with invalid 'sortBy' property ${label}`); @@ -106,8 +109,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest({...template, sortOrder: "ascending"}), "SearchRequest did not instantiate with valid 'sortOrder' property string value 'ascending'"); - for (let [label, value] of suites.strings) { - assert.throws(() => new SearchRequest({...template, sortOrder: value}), + for (let [label, sortOrder] of suites.strings) { + assert.throws(() => new SearchRequest({...template, sortOrder}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending'"}, `SearchRequest instantiated with invalid 'sortOrder' property ${label}`); @@ -118,8 +121,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest({...template, startIndex: 1}), "SearchRequest did not instantiate with valid 'startIndex' property positive integer value '1'"); - for (let [label, value] of suites.numbers) { - assert.throws(() => new SearchRequest({...template, startIndex: value}), + for (let [label, startIndex] of suites.numbers) { + assert.throws(() => new SearchRequest({...template, startIndex}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Expected 'startIndex' parameter to be a positive integer"}, `SearchRequest instantiated with invalid 'startIndex' property ${label}`); @@ -130,8 +133,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest({...template, count: 1}), "SearchRequest did not instantiate with valid 'count' property positive integer value '1'"); - for (let [label, value] of suites.numbers) { - assert.throws(() => new SearchRequest({...template, count: value}), + for (let [label, count] of suites.numbers) { + assert.throws(() => new SearchRequest({...template, count}), {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Expected 'count' parameter to be a positive integer"}, `SearchRequest instantiated with invalid 'count' property ${label}`); @@ -140,13 +143,13 @@ describe("SCIMMY.Messages.SearchRequest", () => { }); describe("#prepare()", () => { - it("should have instance method 'prepare'", () => { + it("should be implemented", () => { assert.ok(typeof (new SearchRequest()).prepare === "function", "Instance method 'prepare' not defined"); }); it("should return the same instance it was called from", () => { - let expected = new SearchRequest(); + const expected = new SearchRequest(); assert.strictEqual(expected.prepare(), expected, "Instance method 'prepare' did not return the same instance it was called from"); @@ -156,8 +159,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({filter: "test"}), "Instance method 'prepare' rejected valid 'filter' property string value 'test'"); - for (let [label, value] of suites.strings) { - assert.throws(() => new SearchRequest().prepare({filter: value}), + for (let [label, filter] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({filter}), {name: "TypeError", message: "Expected 'filter' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'filter' property ${label}`); } @@ -167,8 +170,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({excludedAttributes: ["test"]}), "Instance method 'prepare' rejected valid 'excludedAttributes' property non-empty string array value"); - for (let [label, value] of suites.arrays) { - assert.throws(() => new SearchRequest().prepare({excludedAttributes: value}), + for (let [label, excludedAttributes] of suites.arrays) { + assert.throws(() => new SearchRequest().prepare({excludedAttributes}), {name: "TypeError", message: "Expected 'excludedAttributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'excludedAttributes' property ${label}`); } @@ -178,8 +181,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({attributes: ["test"]}), "Instance method 'prepare' rejected valid 'attributes' property non-empty string array value"); - for (let [label, value] of suites.arrays) { - assert.throws(() => new SearchRequest().prepare({attributes: value}), + for (let [label, attributes] of suites.arrays) { + assert.throws(() => new SearchRequest().prepare({attributes}), {name: "TypeError", message: "Expected 'attributes' parameter to be an array of non-empty strings in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'attributes' property ${label}`); } @@ -189,8 +192,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({sortBy: "test"}), "Instance method 'prepare' rejected valid 'sortBy' property string value 'test'"); - for (let [label, value] of suites.strings) { - assert.throws(() => new SearchRequest().prepare({sortBy: value}), + for (let [label, sortBy] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({sortBy}), {name: "TypeError", message: "Expected 'sortBy' parameter to be a non-empty string in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'sortBy' property ${label}`); } @@ -200,8 +203,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({sortOrder: "ascending"}), "Instance method 'prepare' rejected valid 'sortOrder' property string value 'ascending'"); - for (let [label, value] of suites.strings) { - assert.throws(() => new SearchRequest().prepare({sortOrder: value}), + for (let [label, sortOrder] of suites.strings) { + assert.throws(() => new SearchRequest().prepare({sortOrder}), {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'sortOrder' property ${label}`); } @@ -211,8 +214,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({startIndex: 1}), "Instance method 'prepare' rejected valid 'startIndex' property positive integer value '1'"); - for (let [label, value] of suites.numbers) { - assert.throws(() => new SearchRequest().prepare({startIndex: value}), + for (let [label, startIndex] of suites.numbers) { + assert.throws(() => new SearchRequest().prepare({startIndex}), {name: "TypeError", message: "Expected 'startIndex' parameter to be a positive integer in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'startIndex' property ${label}`); } @@ -222,8 +225,8 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.doesNotThrow(() => new SearchRequest().prepare({count: 1}), "Instance method 'prepare' rejected valid 'count' property positive integer value '1'"); - for (let [label, value] of suites.numbers) { - assert.throws(() => new SearchRequest().prepare({count: value}), + for (let [label, count] of suites.numbers) { + assert.throws(() => new SearchRequest().prepare({count}), {name: "TypeError", message: "Expected 'count' parameter to be a positive integer in 'prepare' method of SearchRequest"}, `Instance method 'prepare' did not reject invalid 'count' property ${label}`); } @@ -231,7 +234,7 @@ describe("SCIMMY.Messages.SearchRequest", () => { }); describe("#apply()", () => { - it("should have instance method 'apply'", () => { + it("should be implemented", () => { assert.ok(typeof (new SearchRequest()).apply === "function", "Instance method 'apply' not defined"); }); @@ -246,5 +249,26 @@ describe("SCIMMY.Messages.SearchRequest", () => { assert.ok(await (new SearchRequest()).apply() instanceof ListResponse, "Instance method 'apply' did not return an instance of ListResponse"); }); + + it("should call through to Resource type when only one given in 'resourceTypes' argument", async function() { + const count = 10; + const stub = sandbox.stub(); + + try { + await (new SearchRequest().prepare({count})).apply([class Test extends createResourceClass() { + constructor(...args) { + stub(...args); + super(...args); + } + + read = sandbox.stub(); + }]); + } catch { + this.skip(); + } + + assert.ok(stub.calledWithMatch({...template, count}), + "Instance method 'apply' did not call through to Resource type when only one given in 'resourceTypes' argument"); + }); }); }); \ No newline at end of file diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 585095d..6ab848a 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -9,14 +9,15 @@ const extension = ["Extension", "urn:ietf:params:scim:schemas:Extension", "An Ex /** * Create a class that extends SCIMMY.Types.Resource, for use in tests * @param {String} name - the name of the Resource to create a class for - * @param {*[]} params - arguments to pass through to the Schema class + * @param {String} id - the ID to pass through to the Schema class + * @param {*[]} rest - arguments to pass through to the Schema class * @returns {typeof Resource} a class that extends SCIMMY.Types.Resource for use in tests */ -export const createResourceClass = (name, ...params) => ( +export const createResourceClass = (name = params.name, id = params.id, ...rest) => ( class Test extends Resource { static #endpoint = `/${name}` static get endpoint() { return Test.#endpoint; } - static #schema = createSchemaClass(name, ...params); + static #schema = createSchemaClass(name, id, ...rest); static get schema() { return Test.#schema; } } ); From 18c95be2136b4482f2ea719ba82acf22c5befacd Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 17 Apr 2023 16:41:53 +1000 Subject: [PATCH 68/93] Fix(SCIMMY.Messages.PatchOp): make error handling more consistent --- src/lib/messages/patchop.js | 45 +++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/src/lib/messages/patchop.js b/src/lib/messages/patchop.js index e3c1fe8..1ac6fd0 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -249,24 +249,7 @@ export class PatchOp { throw new Types.Error(400, "invalidValue", `Attribute 'value' must be an object when 'path' is empty for 'add' op of operation ${index} in PatchOp request body`); // Go through and add the data specified by value - for (let [key, val] of Object.entries(value)) { - if (typeof value[key] === "object") this.#add(index, key, value[key]); - else try { - this.#target[key] = val; - } catch (ex) { - if (ex instanceof Types.Error) { - // Add additional context to SCIM errors - ex.message += ` for 'add' op of operation ${index} in PatchOp request body`; - throw ex; - } else if (ex.message?.endsWith?.("object is not extensible")) { - // Handle errors caused by non-existent attributes in complex values - throw new Types.Error(400, "invalidValue", `Invalid attribute '${key}' for supplied value of 'add' operation ${index} in PatchOp request body`); - } else { - // Rethrow other exceptions as SCIM errors - throw new Types.Error(400, "invalidValue", `Value '${val}' not valid for attribute '${key}' of 'add' operation ${index} in PatchOp request body`); - } - } - } + for (let [key, val] of Object.entries(value)) this.#add(index, key, val); } else { // Validate and extract details about the operation const {targets, property, multiValued, complex} = this.#resolve(index, path, "add"); @@ -292,8 +275,17 @@ export class PatchOp { // The target is not a collection or a complex attribute - assign the value else target[property] = value; } catch (ex) { - // Rethrow exceptions as SCIM errors - throw new Types.Error(400, "invalidValue", ex.message + ` for 'add' op of operation ${index} in PatchOp request body`); + if (ex instanceof Types.Error) { + // Add additional context to SCIM errors + ex.message += ` for 'add' op of operation ${index} in PatchOp request body`; + throw ex; + } else if (ex.message?.endsWith?.("object is not extensible")) { + // Handle errors caused by non-existent attributes in complex values + throw new Types.Error(400, "invalidPath", `Invalid attribute path '${property}' in supplied value for 'add' op of operation ${index} in PatchOp request body`); + } else { + // Rethrow exceptions as SCIM errors + throw new Types.Error(400, "invalidValue", ex.message + ` for 'add' op of operation ${index} in PatchOp request body`); + } } } } @@ -340,8 +332,17 @@ export class PatchOp { if (target[property].length === 0) target[property] = undefined; } } catch (ex) { - // Rethrow exceptions as SCIM errors - throw new Types.Error(400, "invalidValue", ex.message + ` for 'remove' op of operation ${index} in PatchOp request body`); + if (ex instanceof Types.Error) { + // Add additional context to SCIM errors + ex.message += ` for 'remove' op of operation ${index} in PatchOp request body`; + throw ex; + } else if (ex.message?.endsWith?.("object is not extensible")) { + // Handle errors caused by non-existent attributes in complex values + throw new Types.Error(400, "invalidPath", `Invalid attribute path '${property}' in supplied value for 'remove' op of operation ${index} in PatchOp request body`); + } else { + // Rethrow exceptions as SCIM errors + throw new Types.Error(400, "invalidValue", ex.message + ` for 'remove' op of operation ${index} in PatchOp request body`); + } } } } else { From f63155c0a5d8af8527ae3f72e03887afaba80e46 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 17 Apr 2023 16:42:28 +1000 Subject: [PATCH 69/93] Tests(SCIMMY.Messages.PatchOp): add coverage for error handling --- test/lib/messages/patchop.js | 153 ++++++++++++++++++++++++++++----- test/lib/messages/patchop.json | 7 ++ test/lib/types/resource.js | 19 ++-- test/lib/types/schema.js | 14 ++- 4 files changed, 155 insertions(+), 38 deletions(-) diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index b5179a2..98945c4 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -2,13 +2,24 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {Attribute} from "#@/lib/types/attribute.js"; +import {Schema} from "#@/lib/types/schema.js"; import {PatchOp} from "#@/lib/messages/patchop.js"; +import {createSchemaClass} from "../types/schema.js"; const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./patchop.json"), "utf8").then((f) => JSON.parse(f)); const params = {id: "urn:ietf:params:scim:api:messages:2.0:PatchOp"}; const template = {schemas: [params.id]}; +const TestSchema = createSchemaClass({ + attributes: [ + new Attribute("string", "userName", {required: true}), new Attribute("string", "displayName"), + new Attribute("string", "nickName"), new Attribute("string", "password", {direction: "in", returned: false}), + new Attribute("complex", "name", {}, [new Attribute("string", "formatted"), new Attribute("string", "honorificPrefix")]), + new Attribute("complex", "emails", {multiValued: true}, [new Attribute("string", "value"), new Attribute("string", "type")]), + new Attribute("string", "throws") + ] +}); describe("SCIMMY.Messages.PatchOp", () => { describe("@constructor", () => { @@ -68,23 +79,29 @@ describe("SCIMMY.Messages.PatchOp", () => { }); it("should not accept unknown 'op' values in 'Operations' attribute of 'request' parameter", () => { + assert.throws(() => new PatchOp({...template, Operations: [{op: "a string"}]}), + "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); assert.throws(() => new PatchOp({...template, Operations: [{op: "a string"}]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: "Invalid operation 'a string' for operation 1 in PatchOp request body"}, - "PatchOp instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); + "PatchOp did not throw correct SCIMError when instantiated with invalid 'op' value 'a string' in 'Operations' attribute of 'request' parameter"); }); it("should ignore case of 'op' values in 'Operations' attribute of 'request' parameter", () => { - try { - new PatchOp({...template, Operations: [ - {op: "Add", value: {}}, {op: "ADD", value: {}}, {op: "aDd", value: {}}, - {op: "Remove", path: "test"}, {op: "REMOVE", path: "test"}, {op: "rEmOvE", path: "test"}, - {op: "Replace", value: {}}, {op: "REPLACE", value: {}}, {op: "rEpLaCe", value: {}}, - ]}); - } catch (ex) { - if (ex instanceof SCIMMY.Types.Error && ex.message.startsWith("Invalid operation")) { - const op = ex.message.replace("Invalid operation '").split("'").unshift(); - assert.fail(`PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); + const ops = [ + "Add", "ADD", "aDd", + "Remove", "REMOVE", "rEmOvE", + "Replace", "REPLACE", "rEpLaCe" + ]; + + for (let op of ops) { + try { + new PatchOp({...template, Operations: [{op, path: "test", value: {}}]}); + } catch ({message, ...ex}) { + assert.notDeepStrictEqual({...ex, message}, + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `Invalid operation '${op}' for operation 1 in PatchOp request body`}, + `PatchOp did not ignore case of 'op' value '${op}' in 'Operations' attribute of 'request' parameter`); } } }); @@ -120,7 +137,7 @@ describe("SCIMMY.Messages.PatchOp", () => { }); describe("#apply()", () => { - it("should have instance method 'apply'", () => { + it("should be implemented", () => { assert.ok(typeof (new PatchOp({...template, Operations: [{op: "add", value: {}}]})).apply === "function", "Instance method 'apply' not defined"); }); @@ -145,15 +162,25 @@ describe("SCIMMY.Messages.PatchOp", () => { } }); + it("should reject unknown 'op' values in operations", async () => { + const Operations = [{op: "test"}]; + const message = Object.assign(new PatchOp({...template, Operations: [{op: "add", value: {}}]}), {Operations}); + + await assert.rejects(() => message.apply(new TestSchema({id: "1234", userName: "asdf"})), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `Invalid operation 'test' for operation 1 in PatchOp request body`}, + "PatchOp did not throw correct SCIMError at invalid operation with 'op' value 'test' in 'apply' method"); + }); + for (let op of ["add", "remove", "replace"]) { it(`should support simple and complex '${op}' operations`, async () => { const {inbound: {[op]: suite}} = await fixtures; for (let fixture of suite) { const message = new PatchOp({...template, Operations: fixture.ops}); - const source = new SCIMMY.Schemas.User(fixture.source); - const expected = new SCIMMY.Schemas.User(fixture.target, "out"); - const actual = new SCIMMY.Schemas.User(await message.apply(source, (patched) => { + const source = new TestSchema(fixture.source); + const expected = new TestSchema(fixture.target, "out"); + const actual = new TestSchema(await message.apply(source, (patched) => { const expected = JSON.parse(JSON.stringify({...fixture.target, meta: undefined})); const actual = JSON.parse(JSON.stringify({...patched, schemas: undefined, meta: undefined})); @@ -171,33 +198,113 @@ describe("SCIMMY.Messages.PatchOp", () => { if (["add", "replace"].includes(op)) { it(`should expect 'value' to be an object when 'path' is not specified in '${op}' operations`, async () => { - await assert.rejects(() => new PatchOp({...template, Operations: [{op, value: false}]}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + const Operations = [{op, value: false}]; + const target = new TestSchema({id: "1234", userName: "asdf"}); + const message = new PatchOp({...template, Operations}); + + await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidValue", message: `Attribute 'value' must be an object when 'path' is empty for '${op}' op of operation 1 in PatchOp request body`}, `PatchOp did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); }); + + it(`should rethrow extensibility errors as SCIMErrors when 'path' points to nonexistent attribute in '${op}' operations`, async () => { + const Operations = [{op, path: "test", value: ""}]; + const message = new PatchOp({...template, Operations}); + const attribute = new Attribute("string", "test"); + // Wrap the target in a proxy... + const source = new TestSchema({id: "1234", userName: "asdf"}); + const target = new Proxy(source, { + // ...so the constructor can be intercepted... + get: (target, prop) => (prop !== "constructor" ? target[prop] : ( + new Proxy(TestSchema, { + // ...and an unhandled exception can be thrown! + construct: (target, [resource]) => Object.defineProperty({...resource}, "test", { + set: (value) => (source.test = value) + }) + }) + )) + }); + + try { + TestSchema.definition.extend(attribute); + + await assert.rejects(() => message.apply(target), + {name: "SCIMError", status: 400, scimType: "invalidPath", + message: `Invalid attribute path 'test' in supplied value for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not rethrow extensibility error as SCIMError when 'path' pointed to nonexistent attribute in '${op}' operations`); + } finally { + TestSchema.definition.truncate(attribute); + } + }); } + it(`should rethrow SCIMErrors with added location details in '${op}' operations`, async () => { + const Operations = [{op, ...(op === "remove" ? {path: "id"} : {value: {id: "test"}})}]; + const target = new TestSchema({id: "1234", userName: "asdf"}); + const message = new PatchOp({...template, Operations}); + + await assert.rejects(() => message.apply(target), + {name: "SCIMError", status: 400, scimType: "mutability", + message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not rethrow SCIMError with added location details in '${op}' operations`); + }); + + it(`should rethrow other exceptions as SCIMErrors with location details in '${op}' operations`, async () => { + const details = (op === "remove" ? {path: "throws"} : {value: {throws: "test"}}); + const Operations = [{op, ...details}]; + const message = new PatchOp({...template, Operations}); + // Wrap the target in a proxy... + const target = new Proxy(new TestSchema({id: "1234", userName: "asdf"}), { + // ...so the constructor can be intercepted... + get: (target, prop) => (prop !== "constructor" ? target[prop] : ( + new Proxy(TestSchema, { + // ...and an unhandled exception can be thrown! + construct: (target, [resource]) => Object.defineProperty({...resource}, "throws", { + set: (value) => {throw new Error(`Failing as requested with value '${value}'`)} + }) + }) + )) + }); + + await assert.rejects(() => message.apply(target), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: `Failing as requested with value '${details.value?.throws}' for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not rethrow other exception as SCIMError with location details in '${op}' operations`); + }); + it(`should respect attribute mutability in '${op}' operations`, async () => { const Operations = [{op, path: "id", ...(op === "add" ? {value: "asdf"} : {})}]; + const target = new TestSchema({id: "1234", userName: "asdf"}); + const message = new PatchOp({...template, Operations}); - await assert.rejects(() => new PatchOp({...template, Operations}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), - {name: "SCIMError", status: 400, scimType: "invalidValue", + await assert.rejects(() => message.apply(target), + {name: "SCIMError", status: 400, scimType: "mutability", message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, `PatchOp did not respect attribute mutability in '${op}' operations`); }); it(`should not remove required attributes in '${op}' operations`, async () => { const Operations = [{op, path: "userName", ...(op === "add" ? {value: null} : {})}]; + const target = new TestSchema({id: "1234", userName: "asdf"}); + const message = new PatchOp({...template, Operations}); - await assert.rejects(() => new PatchOp({...template, Operations}) - .apply(new SCIMMY.Schemas.User({id: "1234", userName: "asdf"})), + await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidValue", message: `Required attribute 'userName' is missing for '${op}' op of operation 1 in PatchOp request body`}, `PatchOp removed required attributes in '${op}' operations`); }); + + it(`should expect all targeted attributes to exist in '${op}' operations`, async () => { + const Operations = [{op, path: "test", ...(op === "add" ? {value: null} : {})}]; + const target = new TestSchema({id: "1234", userName: "asdf"}); + const message = new PatchOp({...template, Operations}); + + await assert.rejects(() => message.apply(target), + {name: "SCIMError", status: 400, scimType: "invalidPath", + message: `Invalid path 'test' for '${op}' op of operation 1 in PatchOp request body`}, + `PatchOp did not expect target attribute 'test' to exist in '${op}' operations`); + }); } }); }); \ No newline at end of file diff --git a/test/lib/messages/patchop.json b/test/lib/messages/patchop.json index edb7fe4..dc754e0 100644 --- a/test/lib/messages/patchop.json +++ b/test/lib/messages/patchop.json @@ -15,6 +15,13 @@ "ops": [ {"op": "add", "path": "password", "value": "1234"} ] + }, + { + "source": {"id": "1234", "userName": "asdf", "name": {"honorificPrefix": "Mr"}}, + "target": {"id": "1234", "userName": "asdf", "name": {"honorificPrefix": "Mr", "formatted": "Test"}}, + "ops": [ + {"op": "add", "path": "name", "value": {"formatted": "Test"}} + ] } ], "remove": [ diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 6ab848a..005cf7e 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -2,22 +2,17 @@ import assert from "assert"; import {Resource} from "#@/lib/types/resource.js"; import {createSchemaClass} from "./schema.js"; -// Default values to use when creating a resource class in tests -const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test", description: "A Test"}; -const extension = ["Extension", "urn:ietf:params:scim:schemas:Extension", "An Extension"]; - /** * Create a class that extends SCIMMY.Types.Resource, for use in tests - * @param {String} name - the name of the Resource to create a class for - * @param {String} id - the ID to pass through to the Schema class + * @param {String} [name=Test] - the name of the Resource to create a class for * @param {*[]} rest - arguments to pass through to the Schema class * @returns {typeof Resource} a class that extends SCIMMY.Types.Resource for use in tests */ -export const createResourceClass = (name = params.name, id = params.id, ...rest) => ( +export const createResourceClass = (name = "Test", ...rest) => ( class Test extends Resource { static #endpoint = `/${name}` static get endpoint() { return Test.#endpoint; } - static #schema = createSchemaClass(name, id, ...rest); + static #schema = createSchemaClass({...rest, name}); static get schema() { return Test.#schema; } } ); @@ -116,13 +111,13 @@ describe("SCIMMY.Types.Resource", () => { "Static method 'describe' not defined"); }); - const TestResource = createResourceClass(...Object.values(params)); + const TestResource = createResourceClass(); const properties = [ ["name"], ["description"], ["id", "name"], ["schema", "id"], - ["endpoint", "name", `/${params.name}`, ", with leading forward-slash"] + ["endpoint", "name", `/${TestResource.schema.definition.name}`, ", with leading forward-slash"] ]; - for (let [prop, target = prop, expected = params[target], suffix = ""] of properties) { + for (let [prop, target = prop, expected = TestResource.schema.definition[target], suffix = ""] of properties) { it(`should expect '${prop}' property of description to equal '${target}' property of resource's schema definition${suffix}`, () => { assert.strictEqual(TestResource.describe()[prop], expected, `Resource 'describe' method returned '${prop}' property with unexpected value`); @@ -136,7 +131,7 @@ describe("SCIMMY.Types.Resource", () => { it("should expect 'schemaExtensions' property to be included in description when resource is extended", function () { try { - TestResource.extend(createSchemaClass(...extension)); + TestResource.extend(createSchemaClass({name: "Extension", id: "urn:ietf:params:scim:schemas:Extension"})); } catch { this.skip(); } diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index bff2cd4..8d873e9 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -4,13 +4,21 @@ import {SchemaDefinition} from "#@/lib/types/definition.js"; /** * Create a class that extends SCIMMY.Types.Schema, for use in tests - * @param {*[]} params - arguments to pass through to the SchemaDefinition instance + * @param {Object} [params] - parameters to pass through to the SchemaDefinition instance + * @param {String} [params.name] - the name to pass through to the SchemaDefinition instance + * @param {String} [params.id] - the ID to pass through to the SchemaDefinition instance + * @param {String} [params.description] - the description to pass through to the SchemaDefinition instance + * @param {String} [params.attributes] - the attributes to pass through to the SchemaDefinition instance * @returns {typeof Schema} a class that extends SCIMMY.Types.Schema for use in tests */ -export const createSchemaClass = (...params) => ( +export const createSchemaClass = ({name = "Test", id = "urn:ietf:params:scim:schemas:Test", description = "A Test", attributes} = {}) => ( class Test extends Schema { - static #definition = new SchemaDefinition(...params); + static #definition = new SchemaDefinition(name, id, description, attributes); static get definition() { return Test.#definition; } + constructor(resource, direction = "both", basepath, filters) { + super(resource, direction); + Object.assign(this, Test.#definition.coerce(resource, direction, basepath, filters)); + } } ); From 750a0da56c3ae923f118c9cc344f4932d61f78f7 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 19 Apr 2023 17:00:51 +1000 Subject: [PATCH 70/93] Fix(SCIMMY.Messages.BulkRequest): don't wait on preceding ops that reference current op --- src/lib/messages/bulkrequest.js | 208 +++++++++++++++++--------------- 1 file changed, 108 insertions(+), 100 deletions(-) diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js index 39a9814..2491f5a 100644 --- a/src/lib/messages/bulkrequest.js +++ b/src/lib/messages/bulkrequest.js @@ -67,7 +67,7 @@ export class BulkRequest { if (maxOperations > 0 && operations.length > maxOperations) throw new Types.Error(413, null, `Number of operations in BulkRequest exceeds maxOperations limit (${maxOperations})`); - // All seems ok, prepare the BulkRequest body + // All seems OK, prepare the BulkRequest body this.schemas = [BulkRequest.#id]; this.Operations = [...operations]; if (failOnErrors) this.failOnErrors = failOnErrors; @@ -75,7 +75,7 @@ export class BulkRequest { /** * Apply the operations specified by the supplied BulkRequest - * @param {SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing bulk operations, defaults to declared resources + * @param {typeof SCIMMY.Types.Resource[]} [resourceTypes] - resource type classes to be used while processing bulk operations, defaults to declared resources * @returns {SCIMMY.Messages.BulkResponse} a new BulkResponse Message instance with results of the requested operations */ async apply(resourceTypes = Object.values(Resources.declared())) { @@ -89,50 +89,59 @@ export class BulkRequest { else this.#dispatched = true; // Set up easy access to resource types by endpoint, and store pending results - let typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])), - results = [], - // Get a list of POST ops with bulkIds for direct and circular reference resolution - bulkIds = new Map(this.Operations - .filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string") - .map(({bulkId}, index, postOps) => { - // Establish who waits on what, and provide a way for that to happen - let handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}, - value = new Promise((resolve, reject) => Object.assign(handlers, {resolve: resolve, reject: reject})).catch((e) => e); - - return [bulkId, Object.assign(value, handlers)]; - }) - ), - bulkIdTransients = [...bulkIds.keys()], - // Establish error handling for the entire list of operations - errorCount = 0, errorLimit = this.failOnErrors, + const typeMap = new Map(resourceTypes.map((r) => [r.endpoint, r])); + const results = []; + + // Get a map of POST ops with bulkIds for direct and circular reference resolution + const bulkIds = new Map(this.Operations + .filter(o => o.method === "POST" && !!o.bulkId && typeof o.bulkId === "string") + .map(({bulkId}, index, postOps) => { + // Establish who waits on what, and provide a way for that to happen + const handlers = {referencedBy: postOps.filter(({data}) => JSON.stringify(data ?? {}).includes(`bulkId:${bulkId}`)).map(({bulkId}) => bulkId)}; + const value = new Promise((resolve, reject) => Object.assign(handlers, {resolve, reject})); + + return [bulkId, Object.assign(value, handlers)]; + }) + ); + + // Turn them into a list for operation ordering + const bulkIdTransients = [...bulkIds.keys()]; + + // Establish error handling for the entire list of operations + let errorCount = 0, + errorLimit = this.failOnErrors, lastErrorIndex = this.Operations.length + 1; for (let op of this.Operations) results.push((async () => { // Unwrap useful information from the operation - let {method, bulkId, path = "", data} = op, - // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting - [endpoint, id] = (typeof path === "string" ? path : "").substring(1).split("/"), - TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false), - // Construct a location for the response, and prepare common aspects of the result - location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path || undefined), - result = {method: method, bulkId: (typeof bulkId === "string" ? bulkId : undefined), location: (typeof location === "string" ? location : undefined)}, - // Find out if this op waits on any other operations - jsonData = (!!data ? JSON.stringify(data) : ""), - waitingOn = (!jsonData.includes("bulkId:") ? [] : [...new Set([...jsonData.matchAll(/"bulkId:(.+?)"/g)].map(([, id]) => id))]), - // Establish error handling for this operation - index = this.Operations.indexOf(op) + 1, - errorSuffix = `in BulkRequest operation #${index}`, - error = false; - + const {method, bulkId: opBulkId, path = "", data} = op; // Ignore the bulkId unless method is POST - bulkId = (String(method).toUpperCase() === "POST" ? bulkId : undefined); + const bulkId = (String(method).toUpperCase() === "POST" ? opBulkId : undefined); + // Evaluate endpoint and resource ID, and thus what kind of resource we're targeting + const [endpoint, id] = (typeof path === "string" ? path : "").substring(1).split("/"); + const TargetResource = (endpoint ? typeMap.get(`/${endpoint}`) : false); + // Construct a location for the response, and prepare common aspects of the result + const location = (TargetResource ? [TargetResource.basepath() ?? TargetResource.endpoint, id].filter(v => v).join("/") : path || undefined); + const result = {method, bulkId: (typeof bulkId === "string" ? bulkId : undefined), location: (typeof location === "string" ? location : undefined)}; + // Get op data and find out if this op waits on any other operations + const jsonData = (!!data ? JSON.stringify(data) : ""); + const waitingOn = (!jsonData.includes("bulkId:") ? [] : [...new Set([...jsonData.matchAll(/"bulkId:(.+?)"/g)].map(([, id]) => id))]); + const {referencedBy = []} = bulkIds.get(bulkId) ?? {}; + // Establish error handling for this operation + const index = this.Operations.indexOf(op) + 1; + const errorSuffix = `in BulkRequest operation #${index}`; + let error = false; - // If not the first operation, and there's no circular references, wait on all prior operations - if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => bulkIds.get(bulkId).referencedBy.includes(id)))) { - let lastOp = (await Promise.all(results.slice(0, index - 1))).pop(); + // If not the first operation, and there's no circular references, wait on prior operations + if (index > 1 && (!bulkId || !waitingOn.length || !waitingOn.some(id => referencedBy.includes(id)))) { + // Check to see if any preceding operations reference this one + const dependents = referencedBy.map(bulkId => bulkIdTransients.indexOf(bulkId)); + // Then filter them out, so they aren't waited on, and get results of the last operation + const precedingOps = results.slice(0, index - 1).filter((v, i) => !dependents.includes(i)); + const lastOp = (await Promise.all(precedingOps)).pop(); - // If the last operation failed, and error limit reached, bail out here - if (!lastOp || (lastOp.response instanceof ErrorMessage && !(!errorLimit || (errorCount < errorLimit)))) + // If there was last operation, and it failed, and error limit reached, bail out here + if (precedingOps.length && (!lastOp || (lastOp.response instanceof ErrorMessage && !(!errorLimit || (errorCount < errorLimit))))) return; } @@ -173,72 +182,71 @@ export class BulkRequest { else if (!waitingOn.every((id) => bulkIds.has(id))) error = new ErrorMessage(new Types.Error(400, "invalidValue", `No POST operation found matching bulkId '${waitingOn.find((id) => !bulkIds.has(id))}'`)); // If things look OK, attempt to apply the operation - else { - try { - // Go through and wait on any referenced POST bulkIds - for (let referenceId of waitingOn) { - // Find the referenced operation to wait for - let reference = bulkIds.get(referenceId), - referenceIndex = bulkIdTransients.indexOf(referenceId); - - // If the reference is also waiting on us, we have ourselves a circular reference! - if (bulkId && !id && reference.referencedBy.includes(bulkId) && (bulkIdTransients.indexOf(bulkId) < referenceIndex)) { - // Attempt to POST self without reference so referenced operation can complete and give us its ID! - ({id} = await new TargetResource().write(Object.entries(data) - // Remove any values that reference a bulkId - .filter(([,v]) => !JSON.stringify(v).includes("bulkId:")) - .reduce((res, [k, v]) => (((res[k] = v) || true) && res), {}))); - - // Set the ID for future use and resolve pending references - jsonData = JSON.stringify(Object.assign(data, {id: id})); - bulkIds.get(bulkId).resolve(id); - } + else try { + // Get replaceable data for reference resolution + let {data} = op; + + // Go through and wait on any referenced POST bulkIds + for (let referenceId of waitingOn) { + // Find the referenced operation to wait for + const reference = bulkIds.get(referenceId); + const referenceIndex = bulkIdTransients.indexOf(referenceId); + + // If the reference is also waiting on us, we have ourselves a circular reference! + if (bulkId && !id && reference.referencedBy.includes(bulkId) && (bulkIdTransients.indexOf(bulkId) < referenceIndex)) { + // Attempt to POST self without reference so referenced operation can complete and give us its ID! + let {id} = await new TargetResource().write(Object.entries(data) + // Remove any values that reference a bulkId + .filter(([,v]) => !JSON.stringify(v).includes("bulkId:")) + .reduce((res, [k, v]) => Object.assign(res, {[k]: v}), {})); - try { - // Replace reference with real value once resolved - jsonData = jsonData.replaceAll(`bulkId:${referenceId}`, await reference); - data = JSON.parse(jsonData); - } catch (ex) { - // Referenced POST operation precondition failed, remove any created resource and bail out - if (bulkId && id) await new TargetResource(id).dispose(); - - // If we're following on from a prior failure, no need to explain why, otherwise, explain the failure - if (ex instanceof ErrorMessage && (!!errorLimit && errorCount >= errorLimit && index > lastErrorIndex)) return; - else throw new Types.Error(412, null, `Referenced POST operation with bulkId '${referenceId}' was not successful`); - } + // Set the ID for future use and resolve pending references + Object.assign(data, {id}) + bulkIds.get(bulkId).resolve(id); } - // Get ready - let resource = new TargetResource(id), - value; - - // Do the thing! - switch (method.toUpperCase()) { - case "POST": - case "PUT": - value = await resource.write(data); - if (bulkId && !resource.id && value.id) bulkIds.get(bulkId).resolve(value.id); - Object.assign(result, {status: (!bulkId ? "200" : "201"), location: value?.meta?.location}); - break; - - case "PATCH": - value = await resource.patch(data); - Object.assign(result, {status: (value ? "200" : "204")}, (value ? {location: value?.meta?.location} : {})); - break; - - case "DELETE": - await resource.dispose(); - Object.assign(result, {status: "204"}); - break; + try { + // Replace reference with real value once resolved, preserving any new resource ID + data = Object.assign(JSON.parse(jsonData.replaceAll(`bulkId:${referenceId}`, await reference)), {id: data.id}); + } catch (ex) { + // Referenced POST operation precondition failed, remove any created resource and bail out + if (bulkId && data.id) await new TargetResource(data.id).dispose(); + + // If we're following on from a prior failure, no need to explain why, otherwise, explain the failure + if (ex instanceof ErrorMessage && (!!errorLimit && errorCount >= errorLimit && index > lastErrorIndex)) return; + else throw new Types.Error(412, null, `Referenced POST operation with bulkId '${referenceId}' was not successful`); } - } catch (ex) { - // Coerce the exception into a SCIMError - if (!(ex instanceof Types.Error)) - ex = new Types.Error(...(ex instanceof TypeError ? [400, "invalidValue"] : [500, null]), ex.message); - - // Set the error variable for final handling, and reject any pending operations - error = new ErrorMessage(ex); } + + // Get ready + const resource = new TargetResource(data?.id ?? id); + let value; + + // Do the thing! + switch (method.toUpperCase()) { + case "POST": + case "PUT": + value = await resource.write(data); + if (bulkId && !resource.id && value?.id) bulkIds.get(bulkId).resolve(value?.id); + break; + + case "PATCH": + value = await resource.patch(data); + break; + + case "DELETE": + await resource.dispose(); + break; + } + + Object.assign(result, {status: (value ? (!bulkId ? "200" : "201") : "204")}, (value ? {location: value?.meta?.location} : {})); + } catch (ex) { + // Coerce the exception into a SCIMError + if (!(ex instanceof Types.Error)) + ex = new Types.Error(...(ex instanceof TypeError ? [400, "invalidValue"] : [500, null]), ex.message); + + // Set the error variable for final handling, and reject any pending operations + error = new ErrorMessage(ex); } // If there was an error, store result and increment error count From 2e0ba98d9445d7560c179eaa8422901d4e721acd Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 19 Apr 2023 17:01:51 +1000 Subject: [PATCH 71/93] Tests(SCIMMY.Messages.BulkRequest->#apply()): error handling coverage --- test/lib/messages/bulkrequest.js | 54 ++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index 5e96dd7..09982e1 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -36,6 +36,9 @@ class Test extends Resource { // Mock write method that assigns IDs and stores in static instances array async write(instance) { + if (instance?.shouldThrow) + throw new TypeError("Failing as requested"); + // Give the instance an ID and assign data to it const target = Object.assign( (!!this.id ? Test.#instances.find(i => i.id === this.id) : {id: String(++Test.#lastId)}), @@ -131,7 +134,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { }); describe("#apply()", () => { - it("should have instance method 'apply'", () => { + it("should be implemented", () => { assert.ok(typeof (new BulkRequest({...template})).apply === "function", "Instance method 'apply' not defined"); }); @@ -218,7 +221,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { "Instance method 'apply' did not expect 'path' attribute to refer to a valid resource type endpoint"); }); - it("should expect 'path' attribute to NOT specify a resource ID if 'method' is POST", async () => { + it("should expect 'path' attribute not to specify a resource ID if 'method' is POST", async () => { const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test/1", bulkId: "asdf"}]})).apply([Test]))?.Operations; const expected = [{status: "404", method: "POST", bulkId: "asdf", response: { ...new ErrorMessage(new SCIMError(404, null, "POST operation must not target a specific resource in BulkRequest operation #1")) @@ -336,5 +339,52 @@ describe("SCIMMY.Messages.BulkRequest", () => { `Instance method 'apply' did not resolve references in inbound bulkId circular fixture #${suite.indexOf(fixture)+1}`); } }); + + it("should dispose of newly created resources when circular bulkId operations fail", async () => { + // Stub the dispose method on the test class, so it can be spied on + const stub = sandbox.stub(); + const TestStubbed = class extends Test.reset() {dispose = stub}; + // Prepare a list of operations that are circular and will fail + const Operations = [["qwerty", {ref: "bulkId:asdfgh"}], ["asdfgh", {ref: "bulkId:qwerty", shouldThrow: true}]] + .map(([bulkId, data]) => ({method: "POST", path: "/Test", bulkId, data})); + + await (new BulkRequest({...template, Operations})).apply([TestStubbed]); + + assert.ok(stub.called && stub.getCall(0)?.args?.length === 0, + "Instance method 'apply' did not dispose of newly created resource when circular bulkId operation failed"); + }); + + it("should handle precondition failures in dependent bulk operations", async () => { + // Prepare a list of operations where the referenced bulkId operation will fail + const Operations = [["qwerty", {ref: "bulkId:asdfgh"}], ["asdfgh", {shouldThrow: true}]] + .map(([bulkId, data]) => ({method: "POST", path: "/Test", bulkId, data})); + const actual = await (new BulkRequest({...template, Operations})).apply([Test.reset()]); + // Prepare the expected outcomes including the precondition failure and intentional failure + const failedRef = "Referenced POST operation with bulkId 'asdfgh' was not successful"; + const expected = [["qwerty", 412, null, failedRef], ["asdfgh", 400, "invalidValue", "Failing as requested"]] + .map(([bulkId, status, type, reason]) => ([bulkId, String(status), {...new ErrorMessage(new SCIMError(status, type, reason))}])) + .map(([bulkId, status, response]) => ({method: "POST", bulkId, status, response})); + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual.Operations)), expected, + "Instance method 'apply' did not handle precondition failure in dependent bulk operation"); + }); + + for (let [method, fn] of [["POST", "write"], ["PUT", "write"], ["PATCH", "patch"], ["DELETE", "dispose"]]) { + it(`should call resource instance '${fn}' method when 'method' attribute value is ${method}`, async () => { + // Stub the target resource instance method on the test class, so it can be spied on + const stub = sandbox.stub().returns(method !== "DELETE" ? {id: 1} : undefined); + const TestStubbed = class extends Test.reset() {[fn] = stub}; + // Prepare details for an operation that should call the target method + const path = `/Test${method !== "POST" ? "/1" : ""}`; + const bulkId = (method === "POST" ? "asdf" : undefined); + const data = (method !== "DELETE" ? {calledWithMe: true} : undefined); + const Operations = [{method, path, bulkId, data}]; + + await (new BulkRequest({...template, Operations})).apply([TestStubbed]); + + assert.ok(method !== "DELETE" ? stub.calledWithMatch(data) : stub.getCall(0)?.args?.length === 0, + `Instance method 'apply' did not call resource instance '${fn}' method when 'method' attribute value was ${method}`); + }); + } }); }); \ No newline at end of file From 1d0e5730321ec440e85c29f324f9bacc5fff598c Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 19 Apr 2023 17:25:11 +1000 Subject: [PATCH 72/93] Tests(SCIMMY.Types.Filter->#match()): unknown comparator coverage --- src/lib/types/filter.js | 69 +++++++++++++++++++------------------- test/lib/types/filter.js | 7 ++-- test/lib/types/filter.json | 3 ++ 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index ed5c1ef..218f33c 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -174,7 +174,8 @@ export class Filter extends Array { // Cycle through the query and tokenise it until it can't be tokenised anymore while (token = patterns.exec(query)) { // Extract the different matches from the token - let [literal, space, number, boolean, empty, string, grouping, attribute, word] = token; + const [literal, space, number, boolean, empty, string, grouping, attribute, maybeWord] = token; + let word = maybeWord; // If the token isn't whitespace, handle it! if (!space) { @@ -229,7 +230,7 @@ export class Filter extends Array { * @private */ static #operations(tokens, operator) { - let operations = []; + const operations = []; for (let token of [...tokens]) { // Found the target operator token, push preceding tokens as an operation @@ -255,16 +256,16 @@ export class Filter extends Array { // Go through every expression in the list, or handle a singular expression if that's what was given for (let expression of (expressions.every(e => Array.isArray(e)) ? expressions : [expressions])) { // Check if first token is negative for later evaluation - let negative = expression[0] === "not" ? expression.shift() : undefined, - // Extract expression parts and derive object path - [path, comparator, value] = expression, - parts = path.split(pathSeparator).filter(p => p), - target = result; + const negative = expression[0] === "not" ? expression.shift() : undefined; + // Extract expression parts and derive object path + const [path, comparator, expected] = expression; + const parts = path.split(pathSeparator).filter(p => p); + let value = expected, target = result; // Construct the object for (let key of parts) { // Fix the attribute name - let name = `${key[0].toLowerCase()}${key.slice(1)}`; + const name = `${key[0].toLowerCase()}${key.slice(1)}`; // If there's more path to follow, keep digging if (parts.indexOf(key) < parts.length - 1) target = (target[name] = target[name] ?? {}); @@ -290,13 +291,13 @@ export class Filter extends Array { * @private */ static #parse(query = "") { - let tokens = (Array.isArray(query) ? query : Filter.#tokenise(query)), - // Initial pass to check for complexities - simple = !tokens.some(t => ["Operator", "Group"].includes(t.type)), - // Closer inspection in case word tokens contain nested attribute filters - reallySimple = simple && (tokens[0]?.value ?? tokens[0] ?? "") - .split(pathSeparator).every(t => t === multiValuedFilter.exec(t).slice(1).shift()), - results = []; + const results = []; + const tokens = (Array.isArray(query) ? query : Filter.#tokenise(query)); + // Initial pass to check for complexities + const simple = !tokens.some(t => ["Operator", "Group"].includes(t.type)); + // Closer inspection in case word tokens contain nested attribute filters + const reallySimple = simple && (tokens[0]?.value ?? tokens[0] ?? "").split(pathSeparator) + .every(t => t === multiValuedFilter.exec(t).slice(1).shift()); // If there's no operators or groups, and no nested attribute filters, assume the expression is complete if (reallySimple) { @@ -304,15 +305,15 @@ export class Filter extends Array { } // Otherwise, logic and groups need to be evaluated else { - let expressions = []; + const expressions = []; // Go through every "or" branch in the expression for (let branch of Filter.#operations(tokens, "or")) { // Find all "and" joins in the branch - let joins = Filter.#operations(branch, "and"), - // Find all complete expressions, and groups that need evaluating - expression = joins.filter(e => !e.some(t => t.type === "Group")), - groups = joins.filter(e => !expression.includes(e)); + const joins = Filter.#operations(branch, "and"); + // Find all complete expressions, and groups that need evaluating + const expression = joins.filter(e => !e.some(t => t.type === "Group")); + const groups = joins.filter(e => !expression.includes(e)); // Go through every expression and check for nested attribute filters for (let e of expression.splice(0)) { @@ -327,14 +328,14 @@ export class Filter extends Array { } // Otherwise, delve into the path parts for complexities else { - let parts = path.value.split(pathSeparator).filter(p => p), - // Store results and spent path parts - results = [], - spent = []; + const parts = path.value.split(pathSeparator).filter(p => p); + // Store results and spent path parts + const results = []; + const spent = []; for (let part of parts) { // Check for filters in the path part - let [, key = part, filter] = multiValuedFilter.exec(part) ?? []; + const [, key = part, filter] = multiValuedFilter.exec(part) ?? []; // Store the spent path part spent.push(key); @@ -348,8 +349,8 @@ export class Filter extends Array { .map(b => b.every(b => b.every(b => Array.isArray(b))) ? b.flat(1) : b) // Prefix any attribute paths with spent parts .map((branch) => branch.map(join => { - let negative = (join[0] === "not" ? join.shift() : undefined), - [path, comparator, value] = join; + const negative = (join[0] === "not" ? join.shift() : undefined); + const [path, comparator, value] = join; return [negative, `${spent.join(".")}.${path}`, comparator, value].filter(v => v !== undefined); })); @@ -384,13 +385,13 @@ export class Filter extends Array { // Evaluate the groups for (let group of groups.splice(0)) { // Check for negative and extract the group token - let [negate, token = negate] = group, - // Parse the group token, negating and stripping double negatives if necessary - tokens = Filter.#tokenise(token === negate ? token.value : `not ${token.value - .replaceAll(" and ", " and not ").replaceAll(" or ", " or not ") - .replaceAll(" and not not ", " and ").replaceAll(" or not not ", " or ")}`), - // Find all "or" branches in this group - branches = Filter.#operations(tokens, "or"); + const [negate, token = negate] = group; + // Parse the group token, negating and stripping double negatives if necessary + const tokens = Filter.#tokenise(token === negate ? token.value : `not ${token.value + .replaceAll(" and ", " and not ").replaceAll(" or ", " or not ") + .replaceAll(" and not not ", " and ").replaceAll(" or not not ", " or ")}`); + // Find all "or" branches in this group + const branches = Filter.#operations(tokens, "or"); if (branches.length === 1) { // No real branches, so it's probably a simple expression diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 55d7896..d97a8da 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -98,8 +98,8 @@ describe("SCIMMY.Types.Filter", () => { }); }); - describe(".match()", () => { - it("should have instance method 'match'", () => { + describe("#match()", () => { + it("should be implemented", () => { assert.ok(typeof (new Filter()).match === "function", "Instance method 'match' not defined"); }); @@ -112,7 +112,8 @@ describe("SCIMMY.Types.Filter", () => { ["numbers", "correctly compare numeric attribute values"], ["dates", "correctly compare ISO 8601 datetime string attribute values"], ["logicalAnd", "match values against all expressions in a group of logical 'and' expressions for a single attribute"], - ["logicalOr", "match values against any one expression in a group of logical 'or' expressions"] + ["logicalOr", "match values against any one expression in a group of logical 'or' expressions"], + ["unknown", "not match unknown comparators"] ]; for (let [key, label] of targets) { diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 37dcb24..f611f95 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -230,6 +230,9 @@ "logicalOr": [ {"expression": [{"name": {"formatted": ["co", "an"]}}, {"userName": ["co", "ad"]}], "expected": [1, 2, 4]}, {"expression": [{"date": ["gt", "2021-09-08"]}, {"userName": ["co", "a"]}], "expected": [2, 4]} + ], + "unknown": [ + {"expression": {"userName": ["un", "AdeleV"]}, "expected": []} ] } } From 8f2a8c39b6ffee29ace4745fb8ae92c9df513531 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 19 Apr 2023 18:44:13 +1000 Subject: [PATCH 73/93] Fix(SCIMMY.Types.Resource): make constraints conditions more explicit --- src/lib/types/resource.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 4a7138c..f052f48 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -167,7 +167,7 @@ export class Resource { */ constructor(id, config) { // Unwrap params from arguments - let params = (typeof id === "string" || config !== undefined ? config : id) ?? {}; + const params = (typeof id === "string" || config !== undefined ? config : id) ?? {}; // Make sure params is a valid object if (Object(params) !== params || Array.isArray(params)) @@ -213,15 +213,13 @@ export class Resource { // Handle sort and pagination parameters if (["sortBy", "sortOrder", "startIndex", "count"].some(k => k in params)) { - let {sortBy, sortOrder, startIndex: sStartIndex, count: sCount} = params, - startIndex = Number(sStartIndex ?? undefined), - count = Number(sCount ?? undefined); + const {sortBy, sortOrder, startIndex, count} = params; this.constraints = { - ...(sortBy !== undefined ? {sortBy} : {}), + ...(typeof sortBy === "string" ? {sortBy} : {}), ...(["ascending", "descending"].includes(sortOrder) ? {sortOrder} : {}), - ...(!Number.isNaN(startIndex) && Number.isInteger(startIndex) ? {startIndex} : {}), - ...(!Number.isNaN(count) && Number.isInteger(count) ? {count} : {}) + ...(!Number.isNaN(Number(startIndex)) && Number.isInteger(startIndex) ? {startIndex} : {}), + ...(!Number.isNaN(Number(count)) && Number.isInteger(count) ? {count} : {}) }; } } From 0f9a4cbc945a30c2b88b2b64ed812accede2b74e Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 19 Apr 2023 18:45:18 +1000 Subject: [PATCH 74/93] Tests(SCIMMY.Types.Resource): coverage for attributes and constraints instance members --- test/lib/resources.js | 2 +- test/lib/types/resource.js | 231 ++++++++++++++++++++++++------------- 2 files changed, 150 insertions(+), 83 deletions(-) diff --git a/test/lib/resources.js b/test/lib/resources.js index eb67332..ad56bb3 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -364,7 +364,7 @@ export const ResourcesHooks = { }); if (filterable) { - it("should expect query parameters to be an object after instantiation", () => { + it("should expect query parameters to be an object", () => { const fixtures = [ ["number value '1'", 1], ["boolean value 'false'", false], diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 005cf7e..488ada7 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,5 +1,6 @@ import assert from "assert"; import {Resource} from "#@/lib/types/resource.js"; +import {Filter} from "#@/lib/types/filter.js"; import {createSchemaClass} from "./schema.js"; /** @@ -18,97 +19,47 @@ export const createResourceClass = (name = "Test", ...rest) => ( ); describe("SCIMMY.Types.Resource", () => { - it("should have abstract static member 'endpoint'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, "endpoint").get === "function", - "Abstract static member 'endpoint' not defined"); - assert.throws(() => Resource.endpoint, - {name: "TypeError", message: "Method 'get' for property 'endpoint' not implemented by resource 'Resource'"}, - "Static member 'endpoint' not abstract"); - }); - - it("should have abstract static member 'schema'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, "schema").get === "function", - "Abstract static member 'schema' not defined"); - assert.throws(() => Resource.schema, - {name: "TypeError", message: "Method 'get' for property 'schema' not implemented by resource 'Resource'"}, - "Static member 'schema' not abstract"); - }); - - it("should have abstract static method 'basepath'", () => { - assert.ok(typeof Resource.basepath === "function", - "Abstract static method 'basepath' not defined"); - assert.throws(() => Resource.basepath(), - {name: "TypeError", message: "Method 'basepath' not implemented by resource 'Resource'"}, - "Static method 'basepath' not abstract"); - }); - - it("should have abstract static method 'ingress'", () => { - assert.ok(typeof Resource.ingress === "function", - "Abstract static method 'ingress' not defined"); - assert.throws(() => Resource.ingress(), - {name: "TypeError", message: "Method 'ingress' not implemented by resource 'Resource'"}, - "Static method 'ingress' not abstract"); - }); - - it("should have abstract static method 'egress'", () => { - assert.ok(typeof Resource.egress === "function", - "Abstract static method 'egress' not defined"); - assert.throws(() => Resource.egress(), - {name: "TypeError", message: "Method 'egress' not implemented by resource 'Resource'"}, - "Static method 'egress' not abstract"); - }); - - it("should have abstract static method 'degress'", () => { - assert.ok(typeof Resource.degress === "function", - "Abstract static method 'degress' not defined"); - assert.throws(() => Resource.degress(), - {name: "TypeError", message: "Method 'degress' not implemented by resource 'Resource'"}, - "Static method 'degress' not abstract"); - }); - - it("should have abstract instance method 'read'", () => { - assert.ok(typeof (new Resource()).read === "function", - "Abstract instance method 'read' not defined"); - assert.throws(() => new Resource().read(), - {name: "TypeError", message: "Method 'read' not implemented by resource 'Resource'"}, - "Instance method 'read' not abstract"); - }); - - it("should have abstract instance method 'write'", () => { - assert.ok(typeof (new Resource()).write === "function", - "Abstract instance method 'write' not defined"); - assert.throws(() => new Resource().write(), - {name: "TypeError", message: "Method 'write' not implemented by resource 'Resource'"}, - "Instance method 'write' not abstract"); - }); - - it("should have abstract instance method 'patch'", () => { - assert.ok(typeof (new Resource()).patch === "function", - "Abstract instance method 'patch' not defined"); - assert.throws(() => new Resource().patch(), - {name: "TypeError", message: "Method 'patch' not implemented by resource 'Resource'"}, - "Instance method 'patch' not abstract"); - }); + for (let member of ["endpoint", "schema"]) { + describe(`.${member}`, () => { + it("should be defined", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, member).get === "function", + `Abstract static member '${member}' not defined`); + }); + + it("should be abstract", () => { + assert.throws(() => Resource[member], + {name: "TypeError", message: `Method 'get' for property '${member}' not implemented by resource 'Resource'`}, + `Static member '${member}' not abstract`); + }); + }); + } - it("should have abstract instance method 'dispose'", () => { - assert.ok(typeof (new Resource()).dispose === "function", - "Abstract instance method 'dispose' not defined"); - assert.throws(() => new Resource().dispose(), - {name: "TypeError", message: "Method 'dispose' not implemented by resource 'Resource'"}, - "Instance method 'dispose' not abstract"); - }); + for (let method of ["basepath", "ingress", "egress", "degress"]) { + describe(`.${method}()`, () => { + it("should be defined", () => { + assert.ok(typeof Resource[method] === "function", + `Abstract static method '${method}' not defined`); + }); + + it("should be abstract", () => { + assert.throws(() => Resource[method](), + {name: "TypeError", message: `Method '${method}' not implemented by resource 'Resource'`}, + `Static method '${method}' not abstract`); + }); + }); + } describe(".extend()", () => { - it("should have static method 'extend'", () => { + it("should be implemented", () => { assert.ok(typeof Resource.extend === "function", - "Static method 'extend' not defined"); + "Static method 'extend' not implemented"); }); }); describe(".describe()", () => { - it("should have static method 'describe'", () => { + it("should be implemented", () => { assert.ok(typeof Resource.describe === "function", - "Static method 'describe' not defined"); + "Static method 'describe' not implemented"); }); const TestResource = createResourceClass(); @@ -142,4 +93,120 @@ describe("SCIMMY.Types.Resource", () => { "Resource 'describe' method included 'schemaExtensions' property with unexpected value in description"); }); }); + + describe("#filter", () => { + it("should be an instance of SCIMMY.Types.Filter", () => { + assert.ok(new Resource({filter: "userName eq \"Test\""}).filter instanceof Filter, + "Instance member 'filter' was not an instance of SCIMMY.Types.Filter"); + }); + }); + + describe("#attributes", () => { + context("when 'excludedAttributes' query parameter was defined", () => { + it("should be an instance of SCIMMY.Types.Filter", () => { + const resource = new Resource({excludedAttributes: "name"}); + + assert.ok(resource.attributes instanceof Filter, + "Instance member 'attributes' was not an instance of SCIMMY.Types.Filter"); + }); + + it("should expect filter expression to be 'not present'", () => { + const resource = new Resource({excludedAttributes: "name"}); + + assert.ok(resource.attributes.expression === "name np", + "Instance member 'attributes' did not expect filter expression to be 'not present'"); + }); + + it("should expect filter expression to be 'not present' for all specified attributes", () => { + const resource = new Resource({excludedAttributes: "name,displayName"}); + + assert.ok(resource.attributes.expression === "name np and displayName np", + "Instance member 'attributes' did not expect filter expression to be 'not present' for all specified attributes"); + }); + }); + + context("when 'attributes' query parameter was defined", () => { + it("should be an instance of SCIMMY.Types.Filter", () => { + const resource = new Resource({attributes: "userName"}); + + assert.ok(resource.attributes instanceof Filter, + "Instance member 'attributes' was not an instance of SCIMMY.Types.Filter"); + }); + + it("should expect filter expression to be 'present'", () => { + const resource = new Resource({attributes: "name"}); + + assert.ok(resource.attributes.expression === "name pr", + "Instance member 'attributes' did not expect filter expression to be 'present'"); + }); + + it("should expect filter expression to be 'present' for all specified attributes", () => { + const resource = new Resource({attributes: "name,displayName"}); + + assert.ok(resource.attributes.expression === "name pr and displayName pr", + "Instance member 'attributes' did not expect filter expression to be 'present' for all specified attributes"); + }); + + it("should take precedence over 'excludedAttributes' when both defined", () => { + const resource = new Resource({attributes: "name", excludedAttributes: "displayName"}); + + assert.ok(resource.attributes.expression === "name pr", + "Instance member 'attributes' did not give precedence to 'attributes' query parameter"); + }); + }); + }); + + describe("#constraints", () => { + const suite = {sortBy: "name", sortOrder: "ascending", startIndex: 10, count: 10}; + const fixtures = [ + ["string value 'a string'", "a string", ["sortBy"]], + ["number value '1'", 1, ["startIndex", "count"]], + ["boolean value 'false'", false], + ["object value", {}], + ["array value", []] + ]; + + for (let [param, validValue] of Object.entries(suite)) { + context(`when '${param}' query parameter was defined`, () => { + it("should be an object", () => { + const resource = new Resource({[param]: validValue}); + + assert.ok(typeof resource.constraints === "object", + `Instance member 'constraints' was not an object when '${param}' query parameter was defined`); + }); + + for (let [label, value, validFor = []] of fixtures) if (!validFor.includes(param)) { + it(`should not include '${param}' property when '${param}' query parameter had invalid ${label}`, () => { + const resource = new Resource({[param]: value}); + + assert.ok(resource.constraints[param] === undefined, + `Instance member 'constraints' included '${param}' property when '${param}' query parameter had invalid ${label}`); + }); + + it(`should include other valid properties when '${param}' query parameter had invalid ${label}`, () => { + const resource = new Resource({...suite, [param]: value}); + const expected = JSON.parse(JSON.stringify({...suite, [param]: undefined})); + + assert.deepStrictEqual(resource.constraints, expected, + `Instance member 'constraints' did not include valid properties when '${param}' query parameter had invalid ${label}`); + }); + } + }); + } + }); + + for (let method of ["read", "write", "patch", "dispose"]) { + describe(`#${method}()`, () => { + it("should be defined", () => { + assert.ok(typeof (new Resource())[method] === "function", + `Abstract instance method '${method}' not defined`); + }); + + it("should be abstract", () => { + assert.throws(() => new Resource()[method](), + {name: "TypeError", message: `Method '${method}' not implemented by resource 'Resource'`}, + `Instance method '${method}' not abstract`); + }); + }); + } }); \ No newline at end of file From 01a5a05367748a0c0245a26e18b138ccb2c3dec6 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 20 Apr 2023 14:50:05 +1000 Subject: [PATCH 75/93] Tests(*): move hooks to separate location --- test/hooks/resources.js | 575 +++++++++++++++++++++++++++++ test/hooks/schemas.js | 137 +++++++ test/lib/config.js | 13 +- test/lib/messages/bulkrequest.js | 4 +- test/lib/messages/bulkresponse.js | 7 +- test/lib/messages/error.js | 4 +- test/lib/messages/listresponse.js | 275 ++++++++------ test/lib/messages/patchop.js | 30 +- test/lib/messages/searchrequest.js | 10 +- test/lib/resources.js | 566 +--------------------------- test/lib/resources/group.js | 3 +- test/lib/resources/resourcetype.js | 3 +- test/lib/resources/schema.js | 3 +- test/lib/resources/spconfig.js | 3 +- test/lib/resources/user.js | 3 +- test/lib/schemas.js | 121 +----- test/lib/schemas/enterpriseuser.js | 3 +- test/lib/schemas/group.js | 3 +- test/lib/schemas/resourcetype.js | 3 +- test/lib/schemas/spconfig.js | 3 +- test/lib/schemas/user.js | 3 +- test/lib/types/attribute.js | 185 +++++----- test/lib/types/definition.js | 182 +++++---- test/lib/types/error.js | 49 ++- test/lib/types/filter.js | 5 +- test/lib/types/resource.js | 67 ++-- test/lib/types/schema.js | 46 +-- 27 files changed, 1231 insertions(+), 1075 deletions(-) create mode 100644 test/hooks/resources.js create mode 100644 test/hooks/schemas.js diff --git a/test/hooks/resources.js b/test/hooks/resources.js new file mode 100644 index 0000000..19032d5 --- /dev/null +++ b/test/hooks/resources.js @@ -0,0 +1,575 @@ +import assert from "assert"; +import {Schema} from "#@/lib/types/schema.js"; +import {Resource} from "#@/lib/types/resource.js"; +import {SCIMError} from "#@/lib/types/error.js"; +import {ListResponse} from "#@/lib/messages/listresponse.js"; +import {createSchemaClass} from "./schemas.js"; + +/** + * Create a class that extends SCIMMY.Types.Resource, for use in tests + * @param {String} [name=Test] - the name of the Resource to create a class for + * @param {*[]} rest - arguments to pass through to the Schema class + * @returns {typeof Resource} a class that extends SCIMMY.Types.Resource for use in tests + */ +export const createResourceClass = (name = "Test", ...rest) => ( + class Test extends Resource { + static #endpoint = `/${name}` + static get endpoint() { return Test.#endpoint; } + static #schema = createSchemaClass({...rest, name}); + static get schema() { return Test.#schema; } + } +); + +export default { + endpoint: (TargetResource) => (() => { + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), + "Static member 'endpoint' was not implemented"); + }); + + it("should be a string", () => { + assert.ok(typeof TargetResource.endpoint === "string", + "Static member 'endpoint' was not a string"); + }); + }), + schema: (TargetResource, implemented = true) => (() => { + if (!implemented) { + it("should not be implemented", () => { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Static member 'schema' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), + "Static member 'schema' was not implemented"); + }); + + it("should be an instance of Schema", () => { + assert.ok(TargetResource.schema.prototype instanceof Schema, + "Static member 'schema' was not a Schema"); + }); + } + }), + extend: (TargetResource, overrides = false) => (() => { + if (!overrides) { + it("should not be overridden", () => { + assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Static method 'extend' unexpectedly overridden by resource"); + }); + } else { + it("should be overridden", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), + "Static method 'extend' was not overridden"); + assert.ok(typeof TargetResource.extend === "function", + "Static method 'extend' was not a function"); + }); + + it("should throw an 'unsupported' error", () => { + assert.throws(() => TargetResource.extend(), + {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, + "Static method 'extend' did not throw failure"); + }); + } + }), + ingress: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.ingress(), + {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'ingress' unexpectedly implemented by resource"); + }); + } else { + const handler = async (res, instance) => { + const {id} = res ?? {}; + + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const target = Object.assign( + egress.find(f => f.id === id) ?? {id: "5"}, + JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) + ); + + if (!egress.includes(target)) { + if (!!id) throw new Error("Not found"); + else egress.push(target); + } + + return target; + } + }; + + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), + "Static method 'ingress' was not implemented"); + assert.ok(typeof TargetResource.ingress === "function", + "Static method 'ingress' was not a function"); + }); + + it("should set private ingress handler", () => { + assert.strictEqual(TargetResource.ingress(handler), TargetResource, + "Static method 'ingress' did not correctly set ingress handler"); + }); + } + }), + egress: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.egress(), + {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'egress' unexpectedly implemented by resource"); + }); + } else { + const handler = async (res) => { + const {id} = res ?? {}; + + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const target = (!!id ? egress.find(f => f.id === id) : egress); + + if (!target) throw new Error("Not found"); + else return (Array.isArray(target) ? target : [target]); + } + }; + + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), + "Static method 'egress' was not implemented"); + assert.ok(typeof TargetResource.egress === "function", + "Static method 'egress' was not a function"); + }); + + it("should set private egress handler", () => { + assert.strictEqual(TargetResource.egress(handler), TargetResource, + "Static method 'egress' did not correctly set egress handler"); + }); + } + }), + degress: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => TargetResource.degress(), + {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, + "Static method 'degress' unexpectedly implemented by resource"); + }); + } else { + const handler = async (res) => { + const {id} = res ?? {}; + + switch (id) { + case "TypeError": + throw new TypeError("Failing as requested"); + case "SCIMError": + throw new SCIMError(500, "invalidVers", "Failing as requested"); + default: + const {egress} = await fixtures; + const index = egress.indexOf(egress.find(f => f.id === id)); + + if (index < 0) throw new Error("Not found"); + else egress.splice(index, 1); + } + }; + + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), + "Static method 'degress' was not implemented"); + assert.ok(typeof TargetResource.degress === "function", + "Static method 'degress' was not a function"); + }); + + it("should set private degress handler", () => { + assert.strictEqual(TargetResource.degress(handler), TargetResource, + "Static method 'degress' did not correctly set degress handler"); + }); + } + }), + basepath: (TargetResource) => (() => { + it("should be implemented", () => { + assert.ok(Object.getOwnPropertyNames(TargetResource).includes("basepath"), + "Static method 'basepath' was not implemented"); + assert.ok(typeof TargetResource.basepath === "function", + "Static method 'basepath' was not a function"); + }); + + it("should only set basepath once, then do nothing", () => { + const expected = `/scim${TargetResource.endpoint}`; + + TargetResource.basepath("/scim"); + assert.ok(TargetResource.basepath() === (expected), + "Static method 'basepath' did not set or ignore resource basepath"); + + TargetResource.basepath("/test"); + assert.ok(TargetResource.basepath() === (expected), + "Static method 'basepath' did not do nothing when basepath was already set"); + }); + }), + construct: (TargetResource, filterable = true) => (() => { + it("should not require arguments", () => { + assert.doesNotThrow(() => new TargetResource(), + "Resource did not instantiate without arguments"); + }); + + if (filterable) { + it("should expect query parameters to be an object", () => { + const fixtures = [ + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new TargetResource(value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Expected query parameters to be a single complex object value"}, + `Resource did not reject query parameters ${label}`); + } + }); + + it("should expect 'id' argument to be a non-empty string, if supplied", () => { + const fixtures = [ + ["null value", null], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; + + for (let [label, value] of fixtures) { + assert.throws(() => new TargetResource(value, {}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Expected 'id' parameter to be a non-empty string"}, + `Resource did not reject 'id' parameter ${label}`); + } + }); + + const suites = [ + ["filter", "non-empty"], + ["excludedAttributes", "comma-separated list"], + ["attributes", "comma-separated list"] + ]; + + const fixtures = [ + ["object value", {}], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; + + for (let [prop, type] of suites) { + it(`should expect '${prop}' property of query parameters to be a ${type} string`, () => { + for (let [label, value] of fixtures) { + assert.throws(() => new TargetResource({[prop]: value}), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: `Expected ${prop} to be a ${type} string`}, + `Resource did not reject '${prop}' property of query parameter with ${label}`); + } + }); + } + } else { + it("should not instantiate when a filter has been specified", () => { + assert.throws(() => new TargetResource({filter: "id pr"}), + {name: "SCIMError", status: 403, scimType: null, + message: `${TargetResource.name} does not support retrieval by filter`}, + "Internal resource instantiated when filter was specified"); + }); + } + }), + read: (TargetResource, fixtures, listable = true) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().read(), + {name: "TypeError", message: `Method 'read' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'read' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { + assert.ok("read" in (new TargetResource()), + "Instance method 'read' was not implemented"); + assert.ok(typeof (new TargetResource()).read === "function", + "Instance method 'read' was not a function"); + }); + + if (listable) { + it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { + const {egress: expected} = await fixtures; + const result = await (new TargetResource()).read(); + const resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ + ...r, schemas: undefined, meta: undefined, attributes: undefined + }))); + + assert.ok(result instanceof ListResponse, + "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); + assert.deepStrictEqual(resources, expected, + "Instance method 'read' did not return a ListResponse containing all resources from fixture"); + }); + + it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { + const {egress: [expected]} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource(expected.id)).read(), + schemas: undefined, meta: undefined, attributes: undefined + })); + + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource instance by ID"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + await assert.rejects(() => new TargetResource("10").read(), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'read' did not expect requested resource to exist"); + }); + } else { + it("should return the requested resource without sugar-coating", async () => { + const {egress: expected} = await fixtures; + const actual = JSON.parse(JSON.stringify({ + ...await (new TargetResource()).read(), schemas: undefined, meta: undefined + })); + + assert.deepStrictEqual(actual, expected, + "Instance method 'read' did not return the requested resource without sugar-coating"); + }); + } + } + }), + write: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().write(), + {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'write' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { + assert.ok("write" in (new TargetResource()), + "Instance method 'write' was not implemented"); + assert.ok(typeof (new TargetResource()).write === "function", + "Instance method 'write' was not a function"); + }); + + it("should expect 'instance' argument to be an object", async () => { + const suites = [ + ["POST", "new resources"], + ["PUT", "existing resources", "1"] + ]; + + const fixtures = [ + ["string value 'a string'", "a string"], + ["number value '1'", 1], + ["boolean value 'false'", false], + ["array value", []] + ]; + + for (let [method, name, value] of suites) { + const resource = new TargetResource(value); + + await assert.rejects(() => resource.write(), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `Missing request body payload for ${method} operation`}, + `Instance method 'write' did not expect 'instance' parameter to exist for ${name}`); + + for (let [label, value] of fixtures) { + await assert.rejects(() => resource.write(value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: `Operation ${method} expected request body payload to be single complex value`}, + `Instance method 'write' did not reject 'instance' parameter ${label} for ${name}`); + } + } + }); + + it("should call ingress to create new resources when resource instantiated without ID", async () => { + const {ingress: source} = await fixtures; + const result = await (new TargetResource()).write(source); + + assert.deepStrictEqual(await (new TargetResource(result.id)).read(), result, + "Instance method 'write' did not create new resource"); + }); + + it("should call ingress to update existing resources when resource instantiated with ID", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const instance = {...fixture, [target]: "TEST"}; + const expected = await (new TargetResource(fixture.id)).write(instance); + const actual = await (new TargetResource(fixture.id)).read(); + + assert.deepStrictEqual(actual, expected, + "Instance method 'write' did not update existing resource"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("10").write(source), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'write' did not expect requested resource to exist"); + }); + + it("should rethrow SCIMErrors", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("SCIMError").write(source), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'write' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + const {ingress: source} = await fixtures; + await assert.rejects(() => new TargetResource("TypeError").write(source), + {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, + "Instance method 'write' did not rethrow TypeError as SCIMError"); + }); + } + }), + patch: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().patch(), + {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'patch' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { + assert.ok("patch" in (new TargetResource()), + "Instance method 'patch' was not implemented"); + assert.ok(typeof (new TargetResource()).patch === "function", + "Instance method 'patch' was not a function"); + }); + + it("should expect 'message' argument to be an object", async () => { + const fixtures = [ + ["string value 'a string'", "a string"], + ["boolean value 'false'", false], + ["array value", []] + ]; + + await assert.rejects(() => new TargetResource().patch(), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "Missing message body from PatchOp request"}, + "Instance method 'patch' did not expect 'message' parameter to exist"); + + for (let [label, value] of fixtures) { + await assert.rejects(() => new TargetResource().patch(value), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "PatchOp request expected message body to be single complex value"}, + `Instance method 'patch' did not reject 'message' parameter ${label}`); + } + }); + + it("should return nothing when applied PatchOp does not modify resource", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const result = await (new TargetResource(fixture.id)).patch({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", path: target, value: "TEST"}] + }); + + assert.deepStrictEqual(result, undefined, + "Instance method 'patch' did not return nothing when resource was not modified"); + }); + + it("should return the full resource when applied PatchOp modifies resource", async () => { + const {egress: [fixture]} = await fixtures; + const [, target] = Object.keys(fixture); + const expected = {...fixture, [target]: "Test"}; + const actual = await (new TargetResource(fixture.id)).patch({ + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", path: target, value: "Test"}] + }); + + assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, + "Instance method 'patch' did not return the full resource when resource was modified"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("10").patch(message), + {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, + "Instance method 'patch' did not expect requested resource to exist"); + }); + + it("should rethrow SCIMErrors", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("SCIMError").patch(message), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'patch' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + const {ingress: source} = await fixtures; + const message = { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [{op: "add", value: source}] + }; + + await assert.rejects(() => new TargetResource("TypeError").patch(message), + {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, + "Instance method 'patch' did not rethrow TypeError as SCIMError"); + }); + } + }), + dispose: (TargetResource, fixtures) => (() => { + if (!fixtures) { + it("should not be implemented", () => { + assert.throws(() => new TargetResource().dispose(), + {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, + "Instance method 'dispose' unexpectedly implemented by resource"); + }); + } else { + it("should be implemented", () => { + assert.ok("dispose" in (new TargetResource()), + "Instance method 'dispose' was not implemented"); + assert.ok(typeof (new TargetResource()).dispose === "function", + "Instance method 'dispose' was not a function"); + }); + + it("should expect resource instances to have 'id' property", async () => { + await assert.rejects(() => new TargetResource().dispose(), + {name: "SCIMError", status: 404, scimType: null, + message: "DELETE operation must target a specific resource"}, + "Instance method 'dispose' did not expect resource instance to have 'id' property"); + }); + + it("should call degress to delete a resource instance", async () => { + await assert.doesNotReject(() => new TargetResource("5").dispose(), + "Instance method 'dispose' rejected a valid degress request"); + await assert.rejects(() => new TargetResource("5").dispose(), + {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, + "Instance method 'dispose' did not delete the given resource"); + }); + + it("should expect a resource with supplied ID to exist", async () => { + await assert.rejects(() => new TargetResource("5").dispose(), + {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, + "Instance method 'dispose' did not expect requested resource to exist"); + }); + + it("should rethrow SCIMErrors", async () => { + await assert.rejects(() => new TargetResource("SCIMError").dispose(), + {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, + "Instance method 'dispose' did not rethrow SCIM Errors"); + }); + + it("should rethrow TypeErrors as SCIMErrors", async () => { + await assert.rejects(() => new TargetResource("TypeError").dispose(), + {name: "SCIMError", status: 500, scimType: null, message: "Failing as requested"}, + "Instance method 'dispose' did not rethrow TypeError as SCIMError"); + }); + } + }) +}; \ No newline at end of file diff --git a/test/hooks/schemas.js b/test/hooks/schemas.js new file mode 100644 index 0000000..47606b5 --- /dev/null +++ b/test/hooks/schemas.js @@ -0,0 +1,137 @@ +import assert from "assert"; +import {Attribute} from "#@/lib/types/attribute.js"; +import {SchemaDefinition} from "#@/lib/types/definition.js"; +import {Schema} from "#@/lib/types/schema.js"; + +/** + * Create a class that extends SCIMMY.Types.Schema, for use in tests + * @param {Object} [params] - parameters to pass through to the SchemaDefinition instance + * @param {String} [params.name] - the name to pass through to the SchemaDefinition instance + * @param {String} [params.id] - the ID to pass through to the SchemaDefinition instance + * @param {String} [params.description] - the description to pass through to the SchemaDefinition instance + * @param {String} [params.attributes] - the attributes to pass through to the SchemaDefinition instance + * @returns {typeof Schema} a class that extends SCIMMY.Types.Schema for use in tests + */ +export const createSchemaClass = ({name = "Test", id = "urn:ietf:params:scim:schemas:Test", description = "A Test", attributes} = {}) => ( + class Test extends Schema { + static #definition = new SchemaDefinition(name, id, description, attributes); + static get definition() { return Test.#definition; } + constructor(resource, direction = "both", basepath, filters) { + super(resource, direction); + Object.assign(this, Test.#definition.coerce(resource, direction, basepath, filters)); + } + } +); + +export default { + construct: (TargetSchema, fixtures) => (() => { + it("should require 'resource' parameter to be an object at instantiation", () => { + assert.throws(() => new TargetSchema(), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instance did not expect 'resource' parameter to be defined"); + assert.throws(() => new TargetSchema("a string"), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instantiation did not fail with 'resource' parameter string value 'a string'"); + assert.throws(() => new TargetSchema([]), + {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, + "Schema instantiation did not fail with 'resource' parameter array value"); + }); + + it("should validate 'schemas' property of 'resource' parameter if it is defined", () => { + try { + // Add an empty required extension + TargetSchema.extend(new SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"), true); + + assert.throws(() => new TargetSchema({schemas: ["a string"]}), + {name: "SCIMError", status: 400, scimType: "invalidSyntax", + message: "The request body supplied a schema type that is incompatible with this resource"}, + "Schema instance did not validate 'schemas' property of 'resource' parameter"); + assert.throws(() => new TargetSchema({schemas: [TargetSchema.definition.id]}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Test' required by this resource type"}, + "Schema instance did not validate required extensions in 'schemas' property of 'resource' parameter"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + } + }); + + it("should define getters and setters for all attributes in the schema definition", async () => { + const {definition, constructor = {}} = await fixtures; + const attributes = definition.attributes.map(a => a.name); + const instance = new TargetSchema(constructor); + + for (let attrib of attributes) { + assert.ok(attrib in instance, + `Schema instance did not define member '${attrib}'`); + assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).get === "function", + `Schema instance member '${attrib}' was not defined with a 'get' method`); + assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).set === "function", + `Schema instance member '${attrib}' was not defined with a 'set' method`); + } + }); + + it("should include lower-case attribute name property accessor aliases", async () => { + const {constructor = {}} = await fixtures; + const instance = new TargetSchema(constructor); + const [key, value] = Object.entries(constructor).shift(); + + try { + instance[key.toLowerCase()] = value.toUpperCase(); + assert.strictEqual(instance[key], value.toUpperCase(), + "Schema instance did not include lower-case attribute aliases"); + } catch (ex) { + if (ex.scimType !== "mutability") throw ex; + } + }); + + it("should include extension schema attribute property accessor aliases", async () => { + try { + // Add an extension with one attribute + TargetSchema.extend(new SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test", "", [new Attribute("string", "testValue")])); + + // Construct an instance to test against + const {constructor = {}} = await fixtures; + const target = "urn:ietf:params:scim:schemas:Test:testValue"; + const instance = new TargetSchema(constructor); + + instance[target] = "a string"; + assert.strictEqual(instance[target], "a string", + "Schema instance did not include schema extension attribute aliases"); + instance[target.toLowerCase()] = "another string"; + assert.strictEqual(instance[target], "another string", + "Schema instance did not include lower-case schema extension attribute aliases"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + } + }); + + it("should be frozen after instantiation", async () => { + const {constructor = {}} = await fixtures; + const instance = new TargetSchema(constructor); + + assert.throws(() => instance.test = true, + {name: "TypeError", message: "Cannot add property test, object is not extensible"}, + "Schema was extensible after instantiation"); + assert.throws(() => delete instance.meta, + {name: "TypeError", message: `Cannot delete property 'meta' of #<${instance.constructor.name}>`}, + "Schema was not sealed after instantiation"); + }); + }), + definition: (TargetSchema, fixtures) => (() => { + it("should have static member 'definition' that is an instance of SchemaDefinition", () => { + assert.ok("definition" in TargetSchema, + "Static member 'definition' not defined"); + assert.ok(TargetSchema.definition instanceof SchemaDefinition, + "Static member 'definition' was not an instance of SchemaDefinition"); + }); + + it("should produce definition object that matches sample schemas defined in RFC7643", async () => { + const {definition} = await fixtures; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(TargetSchema.definition.describe("/Schemas"))), definition, + "Definition did not match sample schema defined in RFC7643"); + }); + }) +}; \ No newline at end of file diff --git a/test/lib/config.js b/test/lib/config.js index bbbf2dc..832196e 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -1,6 +1,11 @@ import assert from "assert"; import SCIMMY from "#@/scimmy.js"; +/** + * Test whether the returned value of a method call was made immutable + * @param {String} name - the name of the method that produced the object + * @param {Object} config - the returned value of the called method + */ function returnsImmutableObject(name, config) { assert.ok(Object(config) === config && !Array.isArray(config), `Static method '${name}' did not return an object`); @@ -18,9 +23,9 @@ describe("SCIMMY.Config", () => { })); describe(".get()", () => { - it("should have static method 'get'", () => { + it("should be implemented", () => { assert.ok(typeof SCIMMY.Config.get === "function", - "Static method 'get' not defined"); + "Static method 'get' was not implemented"); }); it("should return an immutable object", () => ( @@ -29,9 +34,9 @@ describe("SCIMMY.Config", () => { }); describe(".set()", () => { - it("should have static method 'set'", () => { + it("should be implemented", () => { assert.ok(typeof SCIMMY.Config.set === "function", - "Static method 'set' not defined"); + "Static method 'set' was not implemented"); }); it("should return an immutable object", () => ( diff --git a/test/lib/messages/bulkrequest.js b/test/lib/messages/bulkrequest.js index 09982e1..981ea7a 100644 --- a/test/lib/messages/bulkrequest.js +++ b/test/lib/messages/bulkrequest.js @@ -11,8 +11,10 @@ import {User} from "#@/lib/resources/user.js"; import {Group} from "#@/lib/resources/group.js"; import {BulkRequest} from "#@/lib/messages/bulkrequest.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./bulkrequest.json"), "utf8").then((f) => JSON.parse(f)); +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkRequest"}; const template = {schemas: [params.id], Operations: [{}, {}]}; @@ -136,7 +138,7 @@ describe("SCIMMY.Messages.BulkRequest", () => { describe("#apply()", () => { it("should be implemented", () => { assert.ok(typeof (new BulkRequest({...template})).apply === "function", - "Instance method 'apply' not defined"); + "Instance method 'apply' was not implemented"); }); it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js index d52bd1d..491befe 100644 --- a/test/lib/messages/bulkresponse.js +++ b/test/lib/messages/bulkresponse.js @@ -1,12 +1,13 @@ import assert from "assert"; import {BulkResponse} from "#@/lib/messages/bulkresponse.js"; +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:BulkResponse"}; const template = {schemas: [params.id], Operations: []}; describe("SCIMMY.Messages.BulkResponse", () => { describe("@constructor", () => { - it("should not require arguments at instantiation", () => { + it("should not require arguments", () => { assert.deepStrictEqual({...(new BulkResponse())}, template, "BulkResponse did not instantiate with correct default properties"); }); @@ -28,9 +29,9 @@ describe("SCIMMY.Messages.BulkResponse", () => { }); describe("#resolve()", () => { - it("should have instance method 'resolve'", () => { + it("should be implemented", () => { assert.ok(typeof (new BulkResponse()).resolve === "function", - "Instance method 'resolve' not defined"); + "Instance method 'resolve' was not implemented"); }); it("should return an instance of native Map class", () => { diff --git a/test/lib/messages/error.js b/test/lib/messages/error.js index 08fe2dc..f0454ff 100644 --- a/test/lib/messages/error.js +++ b/test/lib/messages/error.js @@ -4,14 +4,16 @@ import url from "url"; import assert from "assert"; import {Error as ErrorMessage} from "#@/lib/messages/error.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./error.json"), "utf8").then((f) => JSON.parse(f)); +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:Error"}; const template = {schemas: [params.id], status: "500"}; describe("SCIMMY.Messages.Error", () => { describe("@constructor", () => { - it("should not require arguments at instantiation", () => { + it("should not require arguments", () => { assert.deepStrictEqual({...(new ErrorMessage())}, template, "SCIM Error message did not instantiate with correct default properties"); }); diff --git a/test/lib/messages/listresponse.js b/test/lib/messages/listresponse.js index 970dab3..07bd019 100644 --- a/test/lib/messages/listresponse.js +++ b/test/lib/messages/listresponse.js @@ -4,150 +4,181 @@ import url from "url"; import assert from "assert"; import {ListResponse} from "#@/lib/messages/listresponse.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./listresponse.json"), "utf8").then((f) => JSON.parse(f)); +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:ListResponse"}; const template = {schemas: [params.id], Resources: [], totalResults: 0, startIndex: 1, itemsPerPage: 20}; describe("SCIMMY.Messages.ListResponse", () => { - it("should not require arguments at instantiation", () => { - assert.deepStrictEqual({...(new ListResponse())}, template, - "ListResponse did not instantiate with correct default properties"); - }); - - it("should not accept requests with invalid schemas", () => { - assert.throws(() => new ListResponse({schemas: ["nonsense"]}), - {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, - "ListResponse instantiated with invalid 'schemas' property"); - assert.throws(() => - new ListResponse({schemas: [params.id, "nonsense"]}), - {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, - "ListResponse instantiated with invalid 'schemas' property"); - }); - - it("should expect 'startIndex' parameter to be a positive integer", () => { - for (let value of ["a string", -1, 1.5]) { - assert.throws(() => new ListResponse([], {startIndex: value}), - {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, - `ListResponse instantiated with invalid 'startIndex' parameter value '${value}'`); - } - }); - - it("should use 'startIndex' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; + describe("@constructor", () => { + it("should not require arguments", () => { + assert.deepStrictEqual({...(new ListResponse())}, template, + "ListResponse did not instantiate with correct default properties"); + }); - for (let fixture of suite) { - assert.ok((new ListResponse(fixture, {startIndex: 20})).startIndex === fixture.startIndex, - `ListResponse did not use 'startIndex' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } - }); - - it("should expect 'itemsPerPage' parameter to be a positive integer", () => { - for (let value of ["a string", -1, 1.5]) { - assert.throws(() => new ListResponse([], {itemsPerPage: value}), - {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, - `ListResponse instantiated with invalid 'itemsPerPage' parameter value '${value}'`); - } - }); - - it("should use 'itemsPerPage' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; + it("should not accept requests with invalid schemas", () => { + assert.throws(() => new ListResponse({schemas: ["nonsense"]}), + {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, + "ListResponse instantiated with invalid 'schemas' property"); + assert.throws(() => + new ListResponse({schemas: [params.id, "nonsense"]}), + {name: "TypeError", message: "ListResponse request body messages must exclusively specify schema as 'urn:ietf:params:scim:api:messages:2.0:ListResponse'"}, + "ListResponse instantiated with invalid 'schemas' property"); + }); - for (let fixture of suite) { - assert.ok((new ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage === fixture.itemsPerPage, - `ListResponse did not use 'itemsPerPage' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } - }); - - it("should expect 'sortBy' parameter to be a string", () => { - assert.throws(() => new ListResponse([], {sortBy: 1}), - {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, - "ListResponse instantiated with invalid 'sortBy' parameter value '1'"); - assert.throws(() => new ListResponse([], {sortBy: {}}), - {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, - "ListResponse instantiated with invalid 'sortBy' parameter complex value"); - }); - - it("should ignore 'sortOrder' parameter if 'sortBy' parameter is not defined", () => { - assert.doesNotThrow(() => new ListResponse([], {sortOrder: "a string"}), - "ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined"); - }); - - it("should expect 'sortOrder' parameter to be either 'ascending' or 'descending' if 'sortBy' parameter is defined", () => { - assert.throws(() => new ListResponse([], {sortBy: "test", sortOrder: "a string"}), - {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in ListResponse message constructor"}, - "ListResponse accepted invalid 'sortOrder' parameter value 'a string'"); - }); - - it("should have instance member 'Resources' that is an array", () => { - const list = new ListResponse(); + it("should expect 'startIndex' parameter to be a positive integer", () => { + for (let value of ["a string", -1, 1.5]) { + assert.throws(() => new ListResponse([], {startIndex: value}), + {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, + `ListResponse instantiated with invalid 'startIndex' parameter value '${value}'`); + } + }); - assert.ok("Resources" in list, - "Instance member 'Resources' not defined"); - assert.ok(Array.isArray(list.Resources), - "Instance member 'Resources' was not an array"); - }); - - it("should have instance member 'totalResults' that is a non-negative integer", () => { - const list = new ListResponse(); - - assert.ok("totalResults" in list, - "Instance member 'totalResults' not defined"); - assert.ok(typeof list.totalResults === "number" && !Number.isNaN(list.totalResults), - "Instance member 'totalResults' was not a number"); - assert.ok(list.totalResults >= 0 && Number.isInteger(list.totalResults), - "Instance member 'totalResults' was not a non-negative integer"); + it("should expect 'itemsPerPage' parameter to be a positive integer", () => { + for (let value of ["a string", -1, 1.5]) { + assert.throws(() => new ListResponse([], {itemsPerPage: value}), + {name: "TypeError", message: "Expected 'startIndex' and 'itemsPerPage' parameters to be positive integers in ListResponse message constructor"}, + `ListResponse instantiated with invalid 'itemsPerPage' parameter value '${value}'`); + } + }); + + it("should expect 'sortBy' parameter to be a string", () => { + assert.throws(() => new ListResponse([], {sortBy: 1}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, + "ListResponse instantiated with invalid 'sortBy' parameter value '1'"); + assert.throws(() => new ListResponse([], {sortBy: {}}), + {name: "TypeError", message: "Expected 'sortBy' parameter to be a string in ListResponse message constructor"}, + "ListResponse instantiated with invalid 'sortBy' parameter complex value"); + }); + + it("should ignore 'sortOrder' parameter if 'sortBy' parameter is not defined", () => { + assert.doesNotThrow(() => new ListResponse([], {sortOrder: "a string"}), + "ListResponse did not ignore invalid 'sortOrder' parameter when 'sortBy' parameter was not defined"); + }); + + it("should expect 'sortOrder' parameter to be either 'ascending' or 'descending' if 'sortBy' parameter is defined", () => { + assert.throws(() => new ListResponse([], {sortBy: "test", sortOrder: "a string"}), + {name: "TypeError", message: "Expected 'sortOrder' parameter to be either 'ascending' or 'descending' in ListResponse message constructor"}, + "ListResponse accepted invalid 'sortOrder' parameter value 'a string'"); + }); + + it("should only sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source}} = await fixtures; + const list = new ListResponse(source, {sortOrder: "descending"}); + + for (let item of source) { + assert.ok(item.id === list.Resources[source.indexOf(item)]?.id, + "ListResponse unexpectedly sorted resources when 'sortBy' parameter was not supplied"); + } + }); + + it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source, targets: suite}} = await fixtures; + + for (let fixture of suite) { + const list = new ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); + + assert.deepStrictEqual(list.Resources.map(r => r.id), fixture.expected, + `ListResponse did not correctly sort outbound target #${suite.indexOf(fixture)+1} by 'sortBy' value '${fixture.sortBy}'`); + } + }); }); - it("should use 'totalResults' value included in inbound requests", async () => { - const {inbound: suite} = await fixtures; + describe("#Resources", () => { + it("should be defined", () => { + assert.ok("Resources" in new ListResponse(), + "Instance member 'Resources' was not defined"); + }); + + it("should be an array", () => { + assert.ok(Array.isArray(new ListResponse().Resources), + "Instance member 'Resources' was not an array"); + }); - for (let fixture of suite) { - assert.ok((new ListResponse(fixture, {totalResults: 200})).totalResults === fixture.totalResults, - `ListResponse did not use 'totalResults' value included in inbound fixture #${suite.indexOf(fixture)+1}`); - } + it("should not include more resources than 'itemsPerPage' parameter", async () => { + const {outbound: {source}} = await fixtures; + + for (let length of [2, 5, 10, 200, 1]) { + assert.ok((new ListResponse(source, {itemsPerPage: length})).Resources.length <= length, + "Instance member 'Resources' included more resources than specified in 'itemsPerPage' parameter"); + } + }); }); - for (let member of ["startIndex", "itemsPerPage"]) { - it(`should have instance member '${member}' that is a positive integer`, () => { + describe("#startIndex", () => { + it("should be defined", () => { + assert.ok("startIndex" in new ListResponse(), + "Instance member 'startIndex' was not defined"); + }); + + it("should be a positive integer", () => { const list = new ListResponse(); - assert.ok(member in list, - `Instance member '${member}' not defined`); - assert.ok(typeof list[member] === "number" && !Number.isNaN(list[member]), - `Instance member '${member}' was not a number`); - assert.ok(list[member] > 0 && Number.isInteger(list[member]), - `Instance member '${member}' was not a positive integer`); - }); - } - - it("should only sort resources if 'sortBy' parameter is supplied", async () => { - const {outbound: {source}} = await fixtures; - const list = new ListResponse(source, {sortOrder: "descending"}); - - for (let item of source) { - assert.ok(item.id === list.Resources[source.indexOf(item)]?.id, - "ListResponse unexpectedly sorted resources when 'sortBy' parameter was not supplied"); - } + assert.ok(typeof list.startIndex === "number" && !Number.isNaN(list.startIndex), + "Instance member 'startIndex' was not a number"); + assert.ok(list.startIndex > 0 && Number.isInteger(list.startIndex), + "Instance member 'startIndex' was not a positive integer"); + }); + + it("should equal 'startIndex' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; + + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {startIndex: 20})).startIndex === fixture.startIndex, + `Instance member 'startIndex' did not equal 'startIndex' value included in inbound fixture #${suite.indexOf(fixture)+1}`); + } + }); }); - it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { - const {outbound: {source, targets: suite}} = await fixtures; + describe("#itemsPerPage", () => { + it("should be defined", () => { + assert.ok("itemsPerPage" in new ListResponse(), + "Instance member 'itemsPerPage' was not defined"); + }); + + it("should be a positive integer", () => { + const list = new ListResponse(); + + assert.ok(typeof list.itemsPerPage === "number" && !Number.isNaN(list.itemsPerPage), + "Instance member 'itemsPerPage' was not a number"); + assert.ok(list.itemsPerPage > 0 && Number.isInteger(list.itemsPerPage), + "Instance member 'itemsPerPage' was not a positive integer"); + }); - for (let fixture of suite) { - const list = new ListResponse(source, {sortBy: fixture.sortBy, sortOrder: fixture.sortOrder}); + it("should equal 'itemsPerPage' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; - assert.deepStrictEqual(list.Resources.map(r => r.id), fixture.expected, - `ListResponse did not correctly sort outbound target #${suite.indexOf(fixture)+1} by 'sortBy' value '${fixture.sortBy}'`); - } + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {itemsPerPage: 200})).itemsPerPage === fixture.itemsPerPage, + `Instance member 'itemsPerPage' did not equal 'itemsPerPage' value included in inbound fixture #${suite.indexOf(fixture) + 1}`); + } + }); }); - it("should not include more resources than 'itemsPerPage' parameter", async () => { - const {outbound: {source}} = await fixtures; + describe("#totalResults", () => { + it("should be defined", () => { + assert.ok("totalResults" in new ListResponse(), + "Instance member 'totalResults' was not defined"); + }); - for (let length of [2, 5, 10, 200, 1]) { - assert.ok((new ListResponse(source, {itemsPerPage: length})).Resources.length <= length, - "ListResponse included more resources than specified in 'itemsPerPage' parameter"); - } + it("should be a positive integer", () => { + const list = new ListResponse(); + + assert.ok(typeof list.totalResults === "number" && !Number.isNaN(list.totalResults), + "Instance member 'totalResults' was not a number"); + assert.ok(list.totalResults >= 0 && Number.isInteger(list.totalResults), + "Instance member 'totalResults' was not a positive integer"); + }); + + it("should equal 'totalResults' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; + + for (let fixture of suite) { + assert.ok((new ListResponse(fixture, {totalResults: 200})).totalResults === fixture.totalResults, + `Instance member 'totalResults' did not equal 'totalResults' value included in inbound fixture #${suite.indexOf(fixture) + 1}`); + } + }); }); }); \ No newline at end of file diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index 98945c4..e386dd2 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -3,14 +3,16 @@ import path from "path"; import url from "url"; import assert from "assert"; import {Attribute} from "#@/lib/types/attribute.js"; -import {Schema} from "#@/lib/types/schema.js"; import {PatchOp} from "#@/lib/messages/patchop.js"; -import {createSchemaClass} from "../types/schema.js"; +import {createSchemaClass} from "../../hooks/schemas.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./patchop.json"), "utf8").then((f) => JSON.parse(f)); +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:PatchOp"}; const template = {schemas: [params.id]}; +// A Schema class to use in tests const TestSchema = createSchemaClass({ attributes: [ new Attribute("string", "userName", {required: true}), new Attribute("string", "displayName"), @@ -139,26 +141,26 @@ describe("SCIMMY.Messages.PatchOp", () => { describe("#apply()", () => { it("should be implemented", () => { assert.ok(typeof (new PatchOp({...template, Operations: [{op: "add", value: {}}]})).apply === "function", - "Instance method 'apply' not defined"); + "Instance method 'apply' was not implemented"); }); it("should expect message to be dispatched before 'apply' is called", async () => { await assert.rejects(() => new PatchOp().apply(), {name: "TypeError", message: "PatchOp expected message to be dispatched before calling 'apply' method"}, - "PatchOp did not expect message to be dispatched before proceeding with 'apply' method"); + "Instance method 'apply' did not expect message to be dispatched before proceeding"); }); it("should expect 'resource' parameter to be defined", async () => { await assert.rejects(() => new PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(), {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, - "PatchOp did not expect 'resource' parameter to be defined in 'apply' method"); + "Instance method 'apply' did not expect 'resource' parameter to be defined"); }); it("should expect 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { for (let value of [{}, new Date()]) { await assert.rejects(() => new PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(value), {name: "TypeError", message: "Expected 'resource' to be an instance of SCIMMY.Types.Schema in PatchOp 'apply' method"}, - "PatchOp did not verify 'resource' parameter type before proceeding with 'apply' method"); + "Instance method 'apply' did not verify 'resource' parameter type before proceeding"); } }); @@ -169,7 +171,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(new TestSchema({id: "1234", userName: "asdf"})), {name: "SCIMError", status: 400, scimType: "invalidSyntax", message: `Invalid operation 'test' for operation 1 in PatchOp request body`}, - "PatchOp did not throw correct SCIMError at invalid operation with 'op' value 'test' in 'apply' method"); + "Instance method 'apply' did not throw correct SCIMError at invalid operation with 'op' value 'test'"); }); for (let op of ["add", "remove", "replace"]) { @@ -205,7 +207,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidValue", message: `Attribute 'value' must be an object when 'path' is empty for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); + `Instance method 'apply' did not expect 'value' to be an object when 'path' was not specified in '${op}' operations`); }); it(`should rethrow extensibility errors as SCIMErrors when 'path' points to nonexistent attribute in '${op}' operations`, async () => { @@ -232,7 +234,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidPath", message: `Invalid attribute path 'test' in supplied value for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not rethrow extensibility error as SCIMError when 'path' pointed to nonexistent attribute in '${op}' operations`); + `Instance method 'apply' did not rethrow extensibility error as SCIMError when 'path' pointed to nonexistent attribute in '${op}' operations`); } finally { TestSchema.definition.truncate(attribute); } @@ -247,7 +249,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "mutability", message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not rethrow SCIMError with added location details in '${op}' operations`); + `Instance method 'apply' did not rethrow SCIMError with added location details in '${op}' operations`); }); it(`should rethrow other exceptions as SCIMErrors with location details in '${op}' operations`, async () => { @@ -270,7 +272,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidValue", message: `Failing as requested with value '${details.value?.throws}' for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not rethrow other exception as SCIMError with location details in '${op}' operations`); + `Instance method 'apply' did not rethrow other exception as SCIMError with location details in '${op}' operations`); }); it(`should respect attribute mutability in '${op}' operations`, async () => { @@ -281,7 +283,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "mutability", message: `Attribute 'id' already defined and is not mutable for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not respect attribute mutability in '${op}' operations`); + `Instance method 'apply' did not respect attribute mutability in '${op}' operations`); }); it(`should not remove required attributes in '${op}' operations`, async () => { @@ -292,7 +294,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidValue", message: `Required attribute 'userName' is missing for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp removed required attributes in '${op}' operations`); + `Instance method 'apply' removed required attributes in '${op}' operations`); }); it(`should expect all targeted attributes to exist in '${op}' operations`, async () => { @@ -303,7 +305,7 @@ describe("SCIMMY.Messages.PatchOp", () => { await assert.rejects(() => message.apply(target), {name: "SCIMError", status: 400, scimType: "invalidPath", message: `Invalid path 'test' for '${op}' op of operation 1 in PatchOp request body`}, - `PatchOp did not expect target attribute 'test' to exist in '${op}' operations`); + `Instance method 'apply' did not expect target attribute 'test' to exist in '${op}' operations`); }); } }); diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js index acf174c..0efaaf6 100644 --- a/test/lib/messages/searchrequest.js +++ b/test/lib/messages/searchrequest.js @@ -5,9 +5,9 @@ import {ListResponse} from "#@/lib/messages/listresponse.js"; import {User} from "#@/lib/resources/user.js"; import {Group} from "#@/lib/resources/group.js"; import {SearchRequest} from "#@/lib/messages/searchrequest.js"; -import {createResourceClass} from "../types/resource.js"; +import {createResourceClass} from "../../hooks/resources.js"; -// Default values to use in tests +// Default parameter values to use in tests const params = {id: "urn:ietf:params:scim:api:messages:2.0:SearchRequest"}; const template = {schemas: [params.id]}; // List of test suites to run validation against @@ -41,7 +41,7 @@ describe("SCIMMY.Messages.SearchRequest", () => { before(() => sandbox.stub(Resources.default, "declared").returns([User, Group])); describe("@constructor", () => { - it("should not require arguments at instantiation", () => { + it("should not require arguments", () => { assert.deepStrictEqual({...(new SearchRequest())}, template, "SearchRequest did not instantiate with correct default properties"); }); @@ -145,7 +145,7 @@ describe("SCIMMY.Messages.SearchRequest", () => { describe("#prepare()", () => { it("should be implemented", () => { assert.ok(typeof (new SearchRequest()).prepare === "function", - "Instance method 'prepare' not defined"); + "Instance method 'prepare' was not implemented"); }); it("should return the same instance it was called from", () => { @@ -236,7 +236,7 @@ describe("SCIMMY.Messages.SearchRequest", () => { describe("#apply()", () => { it("should be implemented", () => { assert.ok(typeof (new SearchRequest()).apply === "function", - "Instance method 'apply' not defined"); + "Instance method 'apply' was not implemented"); }); it("should expect 'resourceTypes' argument to be an array of Resource type classes", async () => { diff --git a/test/lib/resources.js b/test/lib/resources.js index ad56bb3..0585c21 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -1,10 +1,8 @@ import assert from "assert"; import sinon from "sinon"; import * as Schemas from "#@/lib/schemas.js"; -import {SCIMError} from "#@/lib/types/error.js"; -import SCIMMY from "#@/scimmy.js"; import Resources from "#@/lib/resources.js"; -import {createResourceClass} from "./types/resource.js"; +import {createResourceClass} from "../hooks/resources.js"; describe("SCIMMY.Resources", () => { const sandbox = sinon.createSandbox(); @@ -48,7 +46,7 @@ describe("SCIMMY.Resources", () => { describe(".declare()", () => { it("should be implemented", () => { assert.ok(typeof Resources.declare === "function", - "Static method 'declare' not defined"); + "Static method 'declare' was not implemented"); }); it("should expect 'resource' argument to be an instance of Resource", () => { @@ -108,7 +106,7 @@ describe("SCIMMY.Resources", () => { it("should declare resource type implementation's schema definition to Schemas", () => { for (let resource of [Resources.User, Resources.Group]) { - assert.ok(SCIMMY.Schemas.declare.calledWith(resource.schema.definition), + assert.ok(Schemas.default.declare.calledWith(resource.schema.definition), "Static method 'declare' did not declare resource type implementation's schema definition"); } }); @@ -138,7 +136,7 @@ describe("SCIMMY.Resources", () => { describe(".declared()", () => { it("should be implemented", () => { assert.ok(typeof Resources.declared === "function", - "Static method 'declared' not defined"); + "Static method 'declared' was not implemented"); }); it("should return all declared resources when called without arguments", () => { @@ -163,558 +161,4 @@ describe("SCIMMY.Resources", () => { "Static method 'declared' did not find declaration status of undeclared 'ResourceType' resource by instance"); }); }); -}); - -export const ResourcesHooks = { - endpoint: (TargetResource) => (() => { - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("endpoint"), - "Resource did not implement static member 'endpoint'"); - }); - - it("should be a string", () => { - assert.ok(typeof TargetResource.endpoint === "string", - "Static member 'endpoint' was not a string"); - }); - }), - schema: (TargetResource, implemented = true) => (() => { - if (implemented) { - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Resource did not implement static member 'schema'"); - }); - - it("should be an instance of Schema", () => { - assert.ok(TargetResource.schema.prototype instanceof SCIMMY.Types.Schema, - "Static member 'schema' was not a Schema"); - }); - } else { - it("should not be implemented", () => { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("schema"), - "Static member 'schema' unexpectedly implemented by resource"); - }); - } - }), - extend: (TargetResource, overrides = false) => (() => { - if (!overrides) { - it("should not be overridden", () => { - assert.ok(!Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Static method 'extend' unexpectedly overridden by resource"); - }); - } else { - it("should be overridden", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("extend"), - "Resource did not override static method 'extend'"); - assert.ok(typeof TargetResource.extend === "function", - "Static method 'extend' was not a function"); - }); - - it("should throw an 'unsupported' error", () => { - assert.throws(() => TargetResource.extend(), - {name: "TypeError", message: `SCIM '${TargetResource.name}' resource does not support extension`}, - "Static method 'extend' did not throw failure"); - }); - } - }), - ingress: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => TargetResource.ingress(), - {name: "TypeError", message: `Method 'ingress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'ingress' unexpectedly implemented by resource"); - }); - } else { - const handler = async (res, instance) => { - const {id} = res ?? {}; - - switch (id) { - case "TypeError": - throw new TypeError("Failing as requested"); - case "SCIMError": - throw new SCIMError(500, "invalidVers", "Failing as requested"); - default: - const {egress} = await fixtures; - const target = Object.assign( - egress.find(f => f.id === id) ?? {id: "5"}, - JSON.parse(JSON.stringify({...instance, schemas: undefined, meta: undefined})) - ); - - if (!egress.includes(target)) { - if (!!id) throw new Error("Not found"); - else egress.push(target); - } - - return target; - } - }; - - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("ingress"), - "Resource did not implement static method 'ingress'"); - assert.ok(typeof TargetResource.ingress === "function", - "Static method 'ingress' was not a function"); - }); - - it("should set private ingress handler", () => { - assert.strictEqual(TargetResource.ingress(handler), TargetResource, - "Static method 'ingress' did not correctly set ingress handler"); - }); - } - }), - egress: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => TargetResource.egress(), - {name: "TypeError", message: `Method 'egress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'egress' unexpectedly implemented by resource"); - }); - } else { - const handler = async (res) => { - const {id} = res ?? {}; - - switch (id) { - case "TypeError": - throw new TypeError("Failing as requested"); - case "SCIMError": - throw new SCIMError(500, "invalidVers", "Failing as requested"); - default: - const {egress} = await fixtures; - const target = (!!id ? egress.find(f => f.id === id) : egress); - - if (!target) throw new Error("Not found"); - else return (Array.isArray(target) ? target : [target]); - } - }; - - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("egress"), - "Resource did not implement static method 'egress'"); - assert.ok(typeof TargetResource.egress === "function", - "Static method 'egress' was not a function"); - }); - - it("should set private egress handler", () => { - assert.strictEqual(TargetResource.egress(handler), TargetResource, - "Static method 'egress' did not correctly set egress handler"); - }); - } - }), - degress: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => TargetResource.degress(), - {name: "TypeError", message: `Method 'degress' not implemented by resource '${TargetResource.name}'`}, - "Static method 'degress' unexpectedly implemented by resource"); - }); - } else { - const handler = async (res) => { - const {id} = res ?? {}; - - switch (id) { - case "TypeError": - throw new TypeError("Failing as requested"); - case "SCIMError": - throw new SCIMError(500, "invalidVers", "Failing as requested"); - default: - const {egress} = await fixtures; - const index = egress.indexOf(egress.find(f => f.id === id)); - - if (index < 0) throw new Error("Not found"); - else egress.splice(index, 1); - } - }; - - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("degress"), - "Resource did not implement static method 'degress'"); - assert.ok(typeof TargetResource.degress === "function", - "Static method 'degress' was not a function"); - }); - - it("should set private degress handler", () => { - assert.strictEqual(TargetResource.degress(handler), TargetResource, - "Static method 'degress' did not correctly set degress handler"); - }); - } - }), - basepath: (TargetResource) => (() => { - it("should be implemented", () => { - assert.ok(Object.getOwnPropertyNames(TargetResource).includes("basepath"), - "Static method 'basepath' was not implemented by resource"); - assert.ok(typeof TargetResource.basepath === "function", - "Static method 'basepath' was not a function"); - }); - - it("should only set basepath once, then do nothing", () => { - const expected = `/scim${TargetResource.endpoint}`; - - TargetResource.basepath("/scim"); - assert.ok(TargetResource.basepath() === (expected), - "Static method 'basepath' did not set or ignore resource basepath"); - - TargetResource.basepath("/test"); - assert.ok(TargetResource.basepath() === (expected), - "Static method 'basepath' did not do nothing when basepath was already set"); - }); - }), - construct: (TargetResource, filterable = true) => (() => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new TargetResource(), - "Resource did not instantiate without arguments"); - }); - - if (filterable) { - it("should expect query parameters to be an object", () => { - const fixtures = [ - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource(value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Expected query parameters to be a single complex object value"}, - `Resource did not reject query parameters ${label}`); - } - }); - - it("should expect 'id' argument to be a non-empty string, if supplied", () => { - const fixtures = [ - ["null value", null], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource(value, {}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Expected 'id' parameter to be a non-empty string"}, - `Resource did not reject 'id' parameter ${label}`); - } - }); - - const suites = [ - ["filter", "non-empty"], - ["excludedAttributes", "comma-separated list"], - ["attributes", "comma-separated list"] - ]; - - const fixtures = [ - ["object value", {}], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [prop, type] of suites) { - it(`should expect '${prop}' property of query parameters to be a ${type} string`, () => { - for (let [label, value] of fixtures) { - assert.throws(() => new TargetResource({[prop]: value}), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: `Expected ${prop} to be a ${type} string`}, - `Resource did not reject '${prop}' property of query parameter with ${label}`); - } - }); - } - } else { - it("should not instantiate when a filter has been specified", () => { - assert.throws(() => new TargetResource({filter: "id pr"}), - {name: "SCIMError", status: 403, scimType: null, - message: `${TargetResource.name} does not support retrieval by filter`}, - "Internal resource instantiated when filter was specified"); - }); - } - }), - read: (TargetResource, fixtures, listable = true) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => new TargetResource().read(), - {name: "TypeError", message: `Method 'read' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'read' unexpectedly implemented by resource"); - }); - } else { - it("should be implemented", () => { - assert.ok("read" in (new TargetResource()), - "Resource did not implement instance method 'read'"); - assert.ok(typeof (new TargetResource()).read === "function", - "Instance method 'read' was not a function"); - }); - - if (listable) { - it("should call egress to return a ListResponse if resource was instantiated without an ID", async () => { - const {egress: expected} = await fixtures; - const result = await (new TargetResource()).read(); - const resources = result?.Resources.map(r => JSON.parse(JSON.stringify({ - ...r, schemas: undefined, meta: undefined, attributes: undefined - }))); - - assert.ok(result instanceof SCIMMY.Messages.ListResponse, - "Instance method 'read' did not return a ListResponse when resource instantiated without an ID"); - assert.deepStrictEqual(resources, expected, - "Instance method 'read' did not return a ListResponse containing all resources from fixture"); - }); - - it("should call egress to return the requested resource instance if resource was instantiated with an ID", async () => { - const {egress: [expected]} = await fixtures; - const actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource(expected.id)).read(), - schemas: undefined, meta: undefined, attributes: undefined - })); - - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource instance by ID"); - }); - - it("should expect a resource with supplied ID to exist", async () => { - await assert.rejects(() => new TargetResource("10").read(), - {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, - "Instance method 'read' did not expect requested resource to exist"); - }); - } else { - it("should return the requested resource without sugar-coating", async () => { - const {egress: expected} = await fixtures; - const actual = JSON.parse(JSON.stringify({ - ...await (new TargetResource()).read(), schemas: undefined, meta: undefined - })); - - assert.deepStrictEqual(actual, expected, - "Instance method 'read' did not return the requested resource without sugar-coating"); - }); - } - } - }), - write: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => new TargetResource().write(), - {name: "TypeError", message: `Method 'write' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'write' unexpectedly implemented by resource"); - }); - } else { - it("should be implemented", () => { - assert.ok("write" in (new TargetResource()), - "Resource did not implement instance method 'write'"); - assert.ok(typeof (new TargetResource()).write === "function", - "Instance method 'write' was not a function"); - }); - - it("should expect 'instance' argument to be an object", async () => { - const suites = [ - ["POST", "new resources"], - ["PUT", "existing resources", "1"] - ]; - - const fixtures = [ - ["string value 'a string'", "a string"], - ["number value '1'", 1], - ["boolean value 'false'", false], - ["array value", []] - ]; - - for (let [method, name, value] of suites) { - const resource = new TargetResource(value); - - await assert.rejects(() => resource.write(), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `Missing request body payload for ${method} operation`}, - `Instance method 'write' did not expect 'instance' parameter to exist for ${name}`); - - for (let [label, value] of fixtures) { - await assert.rejects(() => resource.write(value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: `Operation ${method} expected request body payload to be single complex value`}, - `Instance method 'write' did not reject 'instance' parameter ${label} for ${name}`); - } - } - }); - - it("should call ingress to create new resources when resource instantiated without ID", async () => { - const {ingress: source} = await fixtures; - const result = await (new TargetResource()).write(source); - - assert.deepStrictEqual(await (new TargetResource(result.id)).read(), result, - "Instance method 'write' did not create new resource"); - }); - - it("should call ingress to update existing resources when resource instantiated with ID", async () => { - const {egress: [fixture]} = await fixtures; - const [, target] = Object.keys(fixture); - const instance = {...fixture, [target]: "TEST"}; - const expected = await (new TargetResource(fixture.id)).write(instance); - const actual = await (new TargetResource(fixture.id)).read(); - - assert.deepStrictEqual(actual, expected, - "Instance method 'write' did not update existing resource"); - }); - - it("should expect a resource with supplied ID to exist", async () => { - const {ingress: source} = await fixtures; - await assert.rejects(() => new TargetResource("10").write(source), - {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, - "Instance method 'write' did not expect requested resource to exist"); - }); - - it("should rethrow SCIMErrors", async () => { - const {ingress: source} = await fixtures; - await assert.rejects(() => new TargetResource("SCIMError").write(source), - {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, - "Instance method 'write' did not rethrow SCIM Errors"); - }); - - it("should rethrow TypeErrors as SCIMErrors", async () => { - const {ingress: source} = await fixtures; - await assert.rejects(() => new TargetResource("TypeError").write(source), - {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, - "Instance method 'write' did not rethrow TypeError as SCIMError"); - }); - } - }), - patch: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => new TargetResource().patch(), - {name: "TypeError", message: `Method 'patch' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'patch' unexpectedly implemented by resource"); - }); - } else { - it("should be implemented", () => { - assert.ok("patch" in (new TargetResource()), - "Resource did not implement instance method 'patch'"); - assert.ok(typeof (new TargetResource()).patch === "function", - "Instance method 'patch' was not a function"); - }); - - it("should expect 'message' argument to be an object", async () => { - const fixtures = [ - ["string value 'a string'", "a string"], - ["boolean value 'false'", false], - ["array value", []] - ]; - - await assert.rejects(() => new TargetResource().patch(), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "Missing message body from PatchOp request"}, - "Instance method 'patch' did not expect 'message' parameter to exist"); - - for (let [label, value] of fixtures) { - await assert.rejects(() => new TargetResource().patch(value), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "PatchOp request expected message body to be single complex value"}, - `Instance method 'patch' did not reject 'message' parameter ${label}`); - } - }); - - it("should return nothing when applied PatchOp does not modify resource", async () => { - const {egress: [fixture]} = await fixtures; - const [, target] = Object.keys(fixture); - const result = await (new TargetResource(fixture.id)).patch({ - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", path: target, value: "TEST"}] - }); - - assert.deepStrictEqual(result, undefined, - "Instance method 'patch' did not return nothing when resource was not modified"); - }); - - it("should return the full resource when applied PatchOp modifies resource", async () => { - const {egress: [fixture]} = await fixtures; - const [, target] = Object.keys(fixture); - const expected = {...fixture, [target]: "Test"}; - const actual = await (new TargetResource(fixture.id)).patch({ - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", path: target, value: "Test"}] - }); - - assert.deepStrictEqual(JSON.parse(JSON.stringify({...actual, schemas: undefined, meta: undefined})), expected, - "Instance method 'patch' did not return the full resource when resource was modified"); - }); - - it("should expect a resource with supplied ID to exist", async () => { - const {ingress: source} = await fixtures; - const message = { - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", value: source}] - }; - - await assert.rejects(() => new TargetResource("10").patch(message), - {name: "SCIMError", status: 404, scimType: null, message: /10 not found/}, - "Instance method 'patch' did not expect requested resource to exist"); - }); - - it("should rethrow SCIMErrors", async () => { - const {ingress: source} = await fixtures; - const message = { - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", value: source}] - }; - - await assert.rejects(() => new TargetResource("SCIMError").patch(message), - {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, - "Instance method 'patch' did not rethrow SCIM Errors"); - }); - - it("should rethrow TypeErrors as SCIMErrors", async () => { - const {ingress: source} = await fixtures; - const message = { - schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - Operations: [{op: "add", value: source}] - }; - - await assert.rejects(() => new TargetResource("TypeError").patch(message), - {name: "SCIMError", status: 400, scimType: "invalidValue", message: "Failing as requested"}, - "Instance method 'patch' did not rethrow TypeError as SCIMError"); - }); - } - }), - dispose: (TargetResource, fixtures) => (() => { - if (!fixtures) { - it("should not be implemented", () => { - assert.throws(() => new TargetResource().dispose(), - {name: "TypeError", message: `Method 'dispose' not implemented by resource '${TargetResource.name}'`}, - "Instance method 'dispose' unexpectedly implemented by resource"); - }); - } else { - it("should be implemented", () => { - assert.ok("dispose" in (new TargetResource()), - "Resource did not implement instance method 'dispose'"); - assert.ok(typeof (new TargetResource()).dispose === "function", - "Instance method 'dispose' was not a function"); - }); - - it("should expect resource instances to have 'id' property", async () => { - await assert.rejects(() => new TargetResource().dispose(), - {name: "SCIMError", status: 404, scimType: null, - message: "DELETE operation must target a specific resource"}, - "Instance method 'dispose' did not expect resource instance to have 'id' property"); - }); - - it("should call degress to delete a resource instance", async () => { - await assert.doesNotReject(() => new TargetResource("5").dispose(), - "Instance method 'dispose' rejected a valid degress request"); - await assert.rejects(() => new TargetResource("5").dispose(), - {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, - "Instance method 'dispose' did not delete the given resource"); - }); - - it("should expect a resource with supplied ID to exist", async () => { - await assert.rejects(() => new TargetResource("5").dispose(), - {name: "SCIMError", status: 404, scimType: null, message: /5 not found/}, - "Instance method 'dispose' did not expect requested resource to exist"); - }); - - it("should rethrow SCIMErrors", async () => { - await assert.rejects(() => new TargetResource("SCIMError").dispose(), - {name: "SCIMError", status: 500, scimType: "invalidVers", message: "Failing as requested"}, - "Instance method 'dispose' did not rethrow SCIM Errors"); - }); - - it("should rethrow TypeErrors as SCIMErrors", async () => { - await assert.rejects(() => new TargetResource("TypeError").dispose(), - {name: "SCIMError", status: 500, scimType: null, message: "Failing as requested"}, - "Instance method 'dispose' did not rethrow TypeError as SCIMError"); - }); - } - }) -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/resources/group.js b/test/lib/resources/group.js index 4fa8bd2..662a919 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {ResourcesHooks} from "../resources.js"; +import ResourcesHooks from "../../hooks/resources.js"; import {Group} from "#@/lib/resources/group.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/resources/resourcetype.js b/test/lib/resources/resourcetype.js index 5d61ef4..e1097f7 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -5,9 +5,10 @@ import sinon from "sinon"; import * as Resources from "#@/lib/resources.js"; import {User} from "#@/lib/resources/user.js"; import {Group} from "#@/lib/resources/group.js"; -import {ResourcesHooks} from "../resources.js"; +import ResourcesHooks from "../../hooks/resources.js"; import {ResourceType} from "#@/lib/resources/resourcetype.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/resources/schema.js b/test/lib/resources/schema.js index a71bca2..700e963 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -5,9 +5,10 @@ import sinon from "sinon"; import * as Schemas from "#@/lib/schemas.js"; import {User} from "#@/lib/schemas/user.js"; import {Group} from "#@/lib/schemas/group.js"; -import {ResourcesHooks} from "../resources.js"; +import ResourcesHooks from "../../hooks/resources.js"; import {Schema} from "#@/lib/resources/schema.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/resources/spconfig.js b/test/lib/resources/spconfig.js index 29cd988..7086bde 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -3,9 +3,10 @@ import path from "path"; import url from "url"; import sinon from "sinon"; import * as Config from "#@/lib/config.js"; -import {ResourcesHooks} from "../resources.js"; +import ResourcesHooks from "../../hooks/resources.js"; import {ServiceProviderConfig} from "#@/lib/resources/spconfig.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/resources/user.js b/test/lib/resources/user.js index 42ca67c..9771f02 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {ResourcesHooks} from "../resources.js"; +import ResourcesHooks from "../../hooks/resources.js"; import {User} from "#@/lib/resources/user.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas.js b/test/lib/schemas.js index 371b168..67b029a 100644 --- a/test/lib/schemas.js +++ b/test/lib/schemas.js @@ -1,5 +1,5 @@ import assert from "assert"; -import SCIMMY from "#@/scimmy.js"; +import {SchemaDefinition} from "#@/lib/types/definition.js"; import Schemas from "#@/lib/schemas.js"; describe("SCIMMY.Schemas", () => { @@ -104,7 +104,7 @@ describe("SCIMMY.Schemas", () => { }); it("should find nested schema extension definition instances", () => { - const extension = new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); + const extension = new SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"); try { Schemas.User.extend(extension); @@ -115,119 +115,4 @@ describe("SCIMMY.Schemas", () => { } }); }); -}); - -export const SchemasHooks = { - construct: (TargetSchema, fixtures) => (() => { - it("should require 'resource' parameter to be an object at instantiation", () => { - assert.throws(() => new TargetSchema(), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instance did not expect 'resource' parameter to be defined"); - assert.throws(() => new TargetSchema("a string"), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instantiation did not fail with 'resource' parameter string value 'a string'"); - assert.throws(() => new TargetSchema([]), - {name: "TypeError", message: "Expected 'data' parameter to be an object in SchemaDefinition instance"}, - "Schema instantiation did not fail with 'resource' parameter array value"); - }); - - it("should validate 'schemas' property of 'resource' parameter if it is defined", () => { - try { - // Add an empty required extension - TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"), true); - - assert.throws(() => new TargetSchema({schemas: ["a string"]}), - {name: "SCIMError", status: 400, scimType: "invalidSyntax", - message: "The request body supplied a schema type that is incompatible with this resource"}, - "Schema instance did not validate 'schemas' property of 'resource' parameter"); - assert.throws(() => new TargetSchema({schemas: [TargetSchema.definition.id]}), - {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Test' required by this resource type"}, - "Schema instance did not validate required extensions in 'schemas' property of 'resource' parameter"); - } finally { - // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); - } - }); - - it("should define getters and setters for all attributes in the schema definition", async () => { - const {definition, constructor = {}} = await fixtures; - const attributes = definition.attributes.map(a => a.name); - const instance = new TargetSchema(constructor); - - for (let attrib of attributes) { - assert.ok(attrib in instance, - `Schema instance did not define member '${attrib}'`); - assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).get === "function", - `Schema instance member '${attrib}' was not defined with a 'get' method`); - assert.ok(typeof Object.getOwnPropertyDescriptor(instance, attrib).set === "function", - `Schema instance member '${attrib}' was not defined with a 'set' method`); - } - }); - - it("should include lower-case attribute name property accessor aliases", async () => { - const {constructor = {}} = await fixtures; - const instance = new TargetSchema(constructor); - const [key, value] = Object.entries(constructor).shift(); - - try { - instance[key.toLowerCase()] = value.toUpperCase(); - assert.strictEqual(instance[key], value.toUpperCase(), - "Schema instance did not include lower-case attribute aliases"); - } catch (ex) { - if (ex.scimType !== "mutability") throw ex; - } - }); - - it("should include extension schema attribute property accessor aliases", async () => { - try { - // Add an extension with one attribute - TargetSchema.extend(new SCIMMY.Types.SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test", "", [ - new SCIMMY.Types.Attribute("string", "testValue") - ])); - - // Construct an instance to test against - const {constructor = {}} = await fixtures; - const target = "urn:ietf:params:scim:schemas:Test:testValue"; - const instance = new TargetSchema(constructor); - - instance[target] = "a string"; - assert.strictEqual(instance[target], "a string", - "Schema instance did not include schema extension attribute aliases"); - instance[target.toLowerCase()] = "another string"; - assert.strictEqual(instance[target], "another string", - "Schema instance did not include lower-case schema extension attribute aliases"); - } finally { - // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); - } - }); - - it("should be frozen after instantiation", async () => { - const {constructor = {}} = await fixtures; - const instance = new TargetSchema(constructor); - - assert.throws(() => instance.test = true, - {name: "TypeError", message: "Cannot add property test, object is not extensible"}, - "Schema was extensible after instantiation"); - assert.throws(() => delete instance.meta, - {name: "TypeError", message: `Cannot delete property 'meta' of #<${instance.constructor.name}>`}, - "Schema was not sealed after instantiation"); - }); - }), - definition: (TargetSchema, fixtures) => (() => { - it("should have static member 'definition' that is an instance of SchemaDefinition", () => { - assert.ok("definition" in TargetSchema, - "Static member 'definition' not defined"); - assert.ok(TargetSchema.definition instanceof SCIMMY.Types.SchemaDefinition, - "Static member 'definition' was not an instance of SchemaDefinition"); - }); - - it("should produce definition object that matches sample schemas defined in RFC7643", async () => { - const {definition} = await fixtures; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(TargetSchema.definition.describe("/Schemas"))), definition, - "Definition did not match sample schema defined in RFC7643"); - }); - }) -}; \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/schemas/enterpriseuser.js b/test/lib/schemas/enterpriseuser.js index 67f9dec..3102bd8 100644 --- a/test/lib/schemas/enterpriseuser.js +++ b/test/lib/schemas/enterpriseuser.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {SchemasHooks} from "../schemas.js"; +import SchemasHooks from "../../hooks/schemas.js"; import {EnterpriseUser} from "#@/lib/schemas/enterpriseuser.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./enterpriseuser.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/group.js b/test/lib/schemas/group.js index db8c20c..88b6af7 100644 --- a/test/lib/schemas/group.js +++ b/test/lib/schemas/group.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {SchemasHooks} from "../schemas.js"; +import SchemasHooks from "../../hooks/schemas.js"; import {Group} from "#@/lib/schemas/group.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./group.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/resourcetype.js b/test/lib/schemas/resourcetype.js index 91209ca..3955546 100644 --- a/test/lib/schemas/resourcetype.js +++ b/test/lib/schemas/resourcetype.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {SchemasHooks} from "../schemas.js"; +import SchemasHooks from "../../hooks/schemas.js"; import {ResourceType} from "#@/lib/schemas/resourcetype.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./resourcetype.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/spconfig.js b/test/lib/schemas/spconfig.js index 11cda47..83b7941 100644 --- a/test/lib/schemas/spconfig.js +++ b/test/lib/schemas/spconfig.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {SchemasHooks} from "../schemas.js"; +import SchemasHooks from "../../hooks/schemas.js"; import {ServiceProviderConfig} from "#@/lib/schemas/spconfig.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./spconfig.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/schemas/user.js b/test/lib/schemas/user.js index 7ade66b..a3ad605 100644 --- a/test/lib/schemas/user.js +++ b/test/lib/schemas/user.js @@ -1,9 +1,10 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import {SchemasHooks} from "../schemas.js"; +import SchemasHooks from "../../hooks/schemas.js"; import {User} from "#@/lib/schemas/user.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./user.json"), "utf8").then((f) => JSON.parse(f)); diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index 2d5d8c8..4d520d1 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -4,21 +4,34 @@ import url from "url"; import assert from "assert"; import {Attribute} from "#@/lib/types/attribute.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./attribute.json"), "utf8").then((f) => JSON.parse(f)); -export function instantiateFromFixture(fixture) { - const {type, name, mutability: m, uniqueness: u, subAttributes = [], ...config} = fixture; - - return new Attribute( +/** + * Instantiate a new Attribute from the given fixture definition + * @param {Object} fixture - the attribute definition from the fixture + * @returns {SCIMMY.Types.Attribute} a new Attribute instance created from the fixture definition + */ +export const instantiateFromFixture = ({type, name, mutability: m, uniqueness: u, subAttributes = [], ...config}) => ( + new Attribute( type, name, {...(m !== undefined ? {mutable: m} : {}), ...(u !== null ? {uniqueness: !u ? false : u} : {}), ...config}, subAttributes.map(instantiateFromFixture) - ); -} + ) +); -// Run valid and invalid fixtures for different attribute types -function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, assertion}) { - const attribute = new Attribute(type, "test", {...config, multiValued: multiValued}); +/** + * Run valid and invalid coercion fixtures for different attribute types + * @param {String} type - the type of attribute being tested + * @param {Object} fixture - details of the tests to run + * @param {Object.} [fixture.config={}] - additional configuration for the attribute instance + * @param {Boolean} [fixture.multiValued=false] - whether the attribute under test is multi-valued + * @param {[string, any][]} [fixture.valid=[]] - list of valid coercion inputs to verify + * @param {[string, string, any][]} [fixture.invalid=[]] - list of invalid coercion inputs to verify + * @param {Function} [fixture.assertion] - function to call, with invalid input data type, to get expected assertion message + */ +function typedCoercion(type, {config = {}, multiValued = false, valid= [], invalid = [], assertion} = {}) { + const attribute = new Attribute(type, "test", {...config, multiValued}); const target = (multiValued ? attribute.coerce([]) : null); for (let [label, value] of valid) { @@ -34,88 +47,90 @@ function typedCoercion(type, {config = {}, multiValued = false, valid, invalid, } describe("SCIMMY.Types.Attribute", () => { - it("should require valid 'type' argument at instantiation", () => { - assert.throws(() => new Attribute(), - {name: "TypeError", message: "Required parameter 'type' missing from Attribute instantiation"}, - "Attribute instantiated without 'type' argument"); - assert.throws(() => new Attribute("other", "other"), - {name: "TypeError", message: "Type 'other' not recognised in attribute definition 'other'"}, - "Attribute instantiated with unknown 'type' argument"); - }); - - it("should require valid 'name' argument at instantiation", () => { - assert.throws(() => new Attribute("string"), - {name: "TypeError", message: "Required parameter 'name' missing from Attribute instantiation"}, - "Attribute instantiated without 'name' argument"); - - const invalidNames = [ - [".", "invalid.name"], - ["@", "invalid@name"], - ["=", "invalid=name"], - ["%", "invalid%name"] - ]; - - for (let [char, name] of invalidNames) { - assert.throws(() => new Attribute("string", name), - {name: "TypeError", message: `Invalid character '${char}' in name of attribute definition '${name}'`}, - "Attribute instantiated with invalid 'name' argument"); - } + describe("@constructor", () => { + it("should require valid 'type' argument", () => { + assert.throws(() => new Attribute(), + {name: "TypeError", message: "Required parameter 'type' missing from Attribute instantiation"}, + "Attribute instantiated without 'type' argument"); + assert.throws(() => new Attribute("other", "other"), + {name: "TypeError", message: "Type 'other' not recognised in attribute definition 'other'"}, + "Attribute instantiated with unknown 'type' argument"); + }); - assert.ok(new Attribute("string", "validName"), - "Attribute did not instantiate with valid 'name' argument"); - }); - - it("should not accept 'subAttributes' argument if type is not 'complex'", () => { - assert.throws(() => new Attribute("string", "test", {}, [new Attribute("string", "other")]), - {name: "TypeError", message: "Attribute type must be 'complex' when subAttributes are specified in attribute definition 'test'"}, - "Attribute instantiated with subAttributes when type was not 'complex'"); - }); + it("should require valid 'name' argument", () => { + assert.throws(() => new Attribute("string"), + {name: "TypeError", message: "Required parameter 'name' missing from Attribute instantiation"}, + "Attribute instantiated without 'name' argument"); - for (let attrib of ["canonicalValues", "referenceTypes"]) { - it(`should not accept invalid '${attrib}' configuration values`, () => { - for (let value of ["a string", true]) { - assert.throws(() => new Attribute("string", "test", {[attrib]: value}), - {name: "TypeError", message: `Attribute '${attrib}' value must be either a collection or 'false' in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration value '${value}'`); + const invalidNames = [ + [".", "invalid.name"], + ["@", "invalid@name"], + ["=", "invalid=name"], + ["%", "invalid%name"] + ]; + + for (let [char, name] of invalidNames) { + assert.throws(() => new Attribute("string", name), + {name: "TypeError", message: `Invalid character '${char}' in name of attribute definition '${name}'`}, + "Attribute instantiated with invalid 'name' argument"); } + + assert.ok(new Attribute("string", "validName"), + "Attribute did not instantiate with valid 'name' argument"); }); - } - - for (let [attrib, name = attrib] of [["mutable", "mutability"], ["returned"], ["uniqueness"]]) { - it(`should not accept invalid '${attrib}' configuration values`, () => { - assert.throws(() => new Attribute("string", "test", {[attrib]: "a string"}), - {name: "TypeError", message: `Attribute '${name}' value 'a string' not recognised in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration value 'a string'`); - assert.throws(() => new Attribute("string", "test", {[attrib]: 1}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration number value '1'`); - assert.throws(() => new Attribute("string", "test", {[attrib]: {}}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration complex value`); - assert.throws(() => new Attribute("string", "test", {[attrib]: new Date()}), - {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, - `Attribute instantiated with invalid '${attrib}' configuration date value`); + + it("should not accept 'subAttributes' argument if type is not 'complex'", () => { + assert.throws(() => new Attribute("string", "test", {}, [new Attribute("string", "other")]), + {name: "TypeError", message: "Attribute type must be 'complex' when subAttributes are specified in attribute definition 'test'"}, + "Attribute instantiated with subAttributes when type was not 'complex'"); }); - } - - it("should be frozen after instantiation", () => { - const attribute = new Attribute("string", "test"); - assert.throws(() => attribute.test = true, - {name: "TypeError", message: "Cannot add property test, object is not extensible"}, - "Attribute was extensible after instantiation"); - assert.throws(() => attribute.name = "something", - {name: "TypeError", message: "Cannot assign to read only property 'name' of object '#'"}, - "Attribute properties were modifiable after instantiation"); - assert.throws(() => delete attribute.config, - {name: "TypeError", message: "Cannot delete property 'config' of #"}, - "Attribute was not sealed after instantiation"); + for (let attrib of ["canonicalValues", "referenceTypes"]) { + it(`should not accept invalid '${attrib}' configuration values`, () => { + for (let value of ["a string", true]) { + assert.throws(() => new Attribute("string", "test", {[attrib]: value}), + {name: "TypeError", message: `Attribute '${attrib}' value must be either a collection or 'false' in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration value '${value}'`); + } + }); + } + + for (let [attrib, name = attrib] of [["mutable", "mutability"], ["returned"], ["uniqueness"]]) { + it(`should not accept invalid '${attrib}' configuration values`, () => { + assert.throws(() => new Attribute("string", "test", {[attrib]: "a string"}), + {name: "TypeError", message: `Attribute '${name}' value 'a string' not recognised in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration value 'a string'`); + assert.throws(() => new Attribute("string", "test", {[attrib]: 1}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration number value '1'`); + assert.throws(() => new Attribute("string", "test", {[attrib]: {}}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration complex value`); + assert.throws(() => new Attribute("string", "test", {[attrib]: new Date()}), + {name: "TypeError", message: `Attribute '${name}' value must be either string or boolean in attribute definition 'test'`}, + `Attribute instantiated with invalid '${attrib}' configuration date value`); + }); + } + + it("should be frozen after instantiation", () => { + const attribute = new Attribute("string", "test"); + + assert.throws(() => attribute.test = true, + {name: "TypeError", message: "Cannot add property test, object is not extensible"}, + "Attribute was extensible after instantiation"); + assert.throws(() => attribute.name = "something", + {name: "TypeError", message: "Cannot assign to read only property 'name' of object '#'"}, + "Attribute properties were modifiable after instantiation"); + assert.throws(() => delete attribute.config, + {name: "TypeError", message: "Cannot delete property 'config' of #"}, + "Attribute was not sealed after instantiation"); + }); }); describe("#toJSON()", () => { - it("should have instance method 'toJSON'", () => { + it("should be implemented", () => { assert.ok(typeof (new Attribute("string", "test")).toJSON === "function", - "Instance method 'toJSON' not defined"); + "Instance method 'toJSON' was not defined"); }); it("should produce valid SCIM attribute definition objects", async () => { @@ -131,9 +146,9 @@ describe("SCIMMY.Types.Attribute", () => { }); describe("#truncate()", () => { - it("should have instance method 'truncate'", () => { + it("should be implemented", () => { assert.ok(typeof (new Attribute("string", "test")).truncate === "function", - "Instance method 'truncate' not defined"); + "Instance method 'truncate' was not defined"); }); it("should do nothing without arguments", async () => { @@ -171,9 +186,9 @@ describe("SCIMMY.Types.Attribute", () => { }); describe("#coerce()", () => { - it("should have instance method 'coerce'", () => { + it("should be implemented", () => { assert.ok(typeof (new Attribute("string", "test")).coerce === "function", - "Instance method 'coerce' not defined"); + "Instance method 'coerce' was not defined"); }); it("should expect required attributes to have a value", () => { diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 031a237..0712af4 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -8,84 +8,127 @@ import {Attribute} from "#@/lib/types/attribute.js"; import {Filter} from "#@/lib/types/filter.js"; import {instantiateFromFixture} from "./attribute.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./definition.json"), "utf8").then((f) => JSON.parse(f)); +// Default parameter values to use in tests const params = {name: "Test", id: "urn:ietf:params:scim:schemas:Test"}; describe("SCIMMY.Types.SchemaDefinition", () => { - it("should require valid 'name' argument at instantiation", () => { - assert.throws(() => (new SchemaDefinition()), - {name: "TypeError", message: "Required parameter 'name' missing from SchemaDefinition instantiation"}, - "SchemaDefinition instantiated without 'name' parameter"); - assert.throws(() => (new SchemaDefinition("")), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with empty string 'name' parameter"); - assert.throws(() => (new SchemaDefinition(false)), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'name' parameter boolean value 'false'"); - assert.throws(() => (new SchemaDefinition({})), - {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'name' parameter value"); + describe("@constructor", () => { + it("should expect 'name' argument to be defined", () => { + assert.throws(() => (new SchemaDefinition()), + {name: "TypeError", message: "Required parameter 'name' missing from SchemaDefinition instantiation"}, + "SchemaDefinition instantiated without 'name' parameter"); + assert.throws(() => (new SchemaDefinition("")), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with empty string 'name' parameter"); + assert.throws(() => (new SchemaDefinition(false)), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'name' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition({})), + {name: "TypeError", message: "Expected 'name' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'name' parameter value"); + }); + + it("should require valid 'id' argument", () => { + assert.throws(() => (new SchemaDefinition("Test")), + {name: "TypeError", message: "Required parameter 'id' missing from SchemaDefinition instantiation"}, + "SchemaDefinition instantiated without 'id' parameter"); + assert.throws(() => (new SchemaDefinition("Test", "")), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with empty string 'id' parameter"); + assert.throws(() => (new SchemaDefinition("Test", false)), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'id' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition("Test", {})), + {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'id' parameter value"); + }); + + it("should require 'id' to start with 'urn:ietf:params:scim:schemas:'", () => { + assert.throws(() => (new SchemaDefinition("Test", "test")), + {name: "TypeError", message: "Invalid SCIM schema URN namespace 'test' in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with invalid 'id' parameter value 'test'"); + }); + + it("should require valid 'description' argument", () => { + assert.throws(() => (new SchemaDefinition(...Object.values(params), false)), + {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with 'description' parameter boolean value 'false'"); + assert.throws(() => (new SchemaDefinition(...Object.values(params), {})), + {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, + "SchemaDefinition instantiated with complex object 'description' parameter value"); + }); }); - it("should require valid 'id' argument at instantiation", () => { - assert.throws(() => (new SchemaDefinition("Test")), - {name: "TypeError", message: "Required parameter 'id' missing from SchemaDefinition instantiation"}, - "SchemaDefinition instantiated without 'id' parameter"); - assert.throws(() => (new SchemaDefinition("Test", "")), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with empty string 'id' parameter"); - assert.throws(() => (new SchemaDefinition("Test", false)), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'id' parameter boolean value 'false'"); - assert.throws(() => (new SchemaDefinition("Test", {})), - {name: "TypeError", message: "Expected 'id' to be a non-empty string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'id' parameter value"); + describe("#name", () => { + it("should be defined", () => { + assert.ok("name" in new SchemaDefinition(...Object.values(params)), + "Instance member 'name' was not defined"); + }); + + it("should be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.name === "string", + "Instance member 'name' was not a string"); + }); + + it("should equal 'name' argument supplied at instantiation", () => { + assert.strictEqual(new SchemaDefinition(...Object.values(params))?.name, params.name, + "Instance member 'name' did not equal 'name' argument supplied at instantiation"); + }); }); - it("should require 'id' to start with 'urn:ietf:params:scim:schemas:' at instantiation", () => { - assert.throws(() => (new SchemaDefinition("Test", "test")), - {name: "TypeError", message: "Invalid SCIM schema URN namespace 'test' in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with invalid 'id' parameter value 'test'"); - }); + describe("#id", () => { + it("should be defined", () => { + assert.ok("id" in new SchemaDefinition(...Object.values(params)), + "Instance member 'id' was not defined"); + }); - it("should require valid 'description' argument at instantiation", () => { - assert.throws(() => (new SchemaDefinition(...Object.values(params), false)), - {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with 'description' parameter boolean value 'false'"); - assert.throws(() => (new SchemaDefinition(...Object.values(params), {})), - {name: "TypeError", message: "Expected 'description' to be a string in SchemaDefinition instantiation"}, - "SchemaDefinition instantiated with complex object 'description' parameter value"); + it("should be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.id === "string", + "Instance member 'id' was not a string"); + }); + + it("should equal 'id' argument supplied at instantiation", () => { + assert.strictEqual(new SchemaDefinition(...Object.values(params))?.id, params.id, + "Instance member 'id' did not equal 'id' argument supplied at instantiation"); + }); }); - it("should have instance member 'name'", () => { - assert.strictEqual((new SchemaDefinition(...Object.values(params)))?.name, params.name, - "SchemaDefinition did not include instance member 'name'"); - }); + describe("#description", () => { + it("should be defined", () => { + assert.ok("description" in new SchemaDefinition(...Object.values(params)), + "Instance member 'description' was not defined"); + }); - it("should have instance member 'id'", () => { - assert.strictEqual((new SchemaDefinition(...Object.values(params)))?.id, params.id, - "SchemaDefinition did not include instance member 'id'"); - }); + it("should be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.description === "string", + "Instance member 'description' was not a string"); + }); - it("should have instance member 'description'", () => { - assert.ok("description" in (new SchemaDefinition(...Object.values(params))), - "SchemaDefinition did not include instance member 'description'"); + it("should equal 'description' argument supplied at instantiation", () => { + assert.strictEqual(new SchemaDefinition(...Object.values(params), "Test Description")?.description, "Test Description", + "Instance member 'description' did not equal 'description' argument supplied at instantiation"); + }); }); - it("should have instance member 'attributes' that is an array", () => { - const definition = new SchemaDefinition(...Object.values(params)); + describe("#attributes", () => { + it("should be defined", () => { + assert.ok("attributes" in new SchemaDefinition(...Object.values(params)), + "Instance member 'attributes' was not defined"); + }); - assert.ok("attributes" in definition, - "SchemaDefinition did not include instance member 'attributes'"); - assert.ok(Array.isArray(definition.attributes), - "SchemaDefinition instance member 'attributes' was not an array"); + it("should be an array", () => { + assert.ok(Array.isArray(new SchemaDefinition(...Object.values(params))?.attributes), + "Instance member 'attributes' was not an array"); + }); }); describe("#describe()", () => { - it("should have instance method 'describe'", () => { + it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).describe === "function", - "Instance method 'describe' not defined"); + "Instance method 'describe' was not defined"); }); it("should produce valid SCIM schema definition objects", async () => { @@ -104,9 +147,9 @@ describe("SCIMMY.Types.SchemaDefinition", () => { }); describe("#attribute()", () => { - it("should have instance method 'attribute'", () => { + it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).attribute === "function", - "Instance method 'attribute' not defined"); + "Instance method 'attribute' was not defined"); }); it("should find attributes by name", () => { @@ -205,9 +248,9 @@ describe("SCIMMY.Types.SchemaDefinition", () => { }); describe("#extend()", () => { - it("should have instance method 'extend'", () => { + it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).extend === "function", - "Instance method 'extend' not defined"); + "Instance method 'extend' was not defined"); }); it("should expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances", () => { @@ -262,9 +305,9 @@ describe("SCIMMY.Types.SchemaDefinition", () => { }); describe("#truncate()", () => { - it("should have instance method 'truncate'", () => { + it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).truncate === "function", - "Instance method 'truncate' not defined"); + "Instance method 'truncate' was not defined"); }); it("should do nothing without arguments", () => { @@ -305,6 +348,15 @@ describe("SCIMMY.Types.SchemaDefinition", () => { "Instance method 'truncate' did not remove named attribute directly included in the definition"); }); + it("should remove named sub-attributes included in the definition", () => { + const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("complex", "test", {}, [new Attribute("string", "test")])]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: [new Attribute("complex", "test").toJSON()]})); + const actual = JSON.parse(JSON.stringify(definition.truncate("test.test").describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove named sub-attribute included in the definition"); + }); + it("should expect named attributes and sub-attributes to exist", () => { const definition = new SchemaDefinition(...Object.values(params)); @@ -321,9 +373,9 @@ describe("SCIMMY.Types.SchemaDefinition", () => { }); describe("#coerce()", () => { - it("should have instance method 'coerce'", () => { + it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).coerce === "function", - "Instance method 'coerce' not defined"); + "Instance method 'coerce' was not defined"); }); it("should expect 'data' argument to be an object", () => { diff --git a/test/lib/types/error.js b/test/lib/types/error.js index 2236081..b6b1be5 100644 --- a/test/lib/types/error.js +++ b/test/lib/types/error.js @@ -2,33 +2,48 @@ import assert from "assert"; import {SCIMError} from "#@/lib/types/error.js"; describe("SCIMMY.Types.Error", () => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new SCIMError(), - "Error type class did not instantiate without arguments"); - }); - it("should extend native 'Error' class", () => { assert.ok(new SCIMError() instanceof Error, "Error type class did not extend native 'Error' class"); }); - it("should have instance member 'name' with value 'SCIMError'", () => { - assert.strictEqual((new SCIMError())?.name, "SCIMError", - "Error type class did not include instance member 'name' with value 'SCIMError'"); + describe("@constructor", () => { + it("should not require arguments", () => { + assert.doesNotThrow(() => new SCIMError(), + "Error type class did not instantiate without arguments"); + }); + }); + + describe("#name", () => { + it("should be defined", () => { + assert.ok("name" in new SCIMError(), + "Instance member 'name' was not defined"); + }); + + it("should have value 'SCIMError'", () => { + assert.strictEqual((new SCIMError())?.name, "SCIMError", + "Instance member 'name' did not have value 'SCIMError'"); + }); }); - it("should have instance member 'status'", () => { - assert.ok("status" in (new SCIMError()), - "Error type class did not include instance member 'status'"); + describe("#status", () => { + it("should be defined", () => { + assert.ok("status" in new SCIMError(), + "Instance member 'status' was not defined"); + }); }); - it("should have instance member 'scimType'", () => { - assert.ok("scimType" in (new SCIMError()), - "Error type class did not include instance member 'scimType'"); + describe("#scimType", () => { + it("should be defined", () => { + assert.ok("scimType" in new SCIMError(), + "Instance member 'scimType' was not defined"); + }); }); - it("should have instance member 'message'", () => { - assert.ok("message" in (new SCIMError()), - "Error type class did not include instance member 'message'"); + describe("#message", () => { + it("should be defined", () => { + assert.ok("message" in new SCIMError(), + "Instance member 'message' was not defined"); + }); }); }); \ No newline at end of file diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index d97a8da..e957caa 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -4,6 +4,7 @@ import url from "url"; import assert from "assert"; import {Filter} from "#@/lib/types/filter.js"; +// Load data to use in tests from adjacent JSON file const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); const fixtures = fs.readFile(path.join(basepath, "./filter.json"), "utf8").then((f) => JSON.parse(f)); @@ -14,7 +15,7 @@ describe("SCIMMY.Types.Filter", () => { }); describe("@constructor", () => { - it("should not require arguments at instantiation", () => { + it("should not require arguments", () => { assert.doesNotThrow(() => new Filter(), "Filter type class did not instantiate without arguments"); }); @@ -101,7 +102,7 @@ describe("SCIMMY.Types.Filter", () => { describe("#match()", () => { it("should be implemented", () => { assert.ok(typeof (new Filter()).match === "function", - "Instance method 'match' not defined"); + "Instance method 'match' not implemented"); }); const targets = [ diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 488ada7..67f297f 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,35 +1,21 @@ import assert from "assert"; import {Resource} from "#@/lib/types/resource.js"; import {Filter} from "#@/lib/types/filter.js"; -import {createSchemaClass} from "./schema.js"; - -/** - * Create a class that extends SCIMMY.Types.Resource, for use in tests - * @param {String} [name=Test] - the name of the Resource to create a class for - * @param {*[]} rest - arguments to pass through to the Schema class - * @returns {typeof Resource} a class that extends SCIMMY.Types.Resource for use in tests - */ -export const createResourceClass = (name = "Test", ...rest) => ( - class Test extends Resource { - static #endpoint = `/${name}` - static get endpoint() { return Test.#endpoint; } - static #schema = createSchemaClass({...rest, name}); - static get schema() { return Test.#schema; } - } -); +import {createSchemaClass} from "../../hooks/schemas.js"; +import {createResourceClass} from "../../hooks/resources.js"; describe("SCIMMY.Types.Resource", () => { for (let member of ["endpoint", "schema"]) { describe(`.${member}`, () => { it("should be defined", () => { assert.ok(typeof Object.getOwnPropertyDescriptor(Resource, member).get === "function", - `Abstract static member '${member}' not defined`); + `Static member '${member}' was not defined`); }); it("should be abstract", () => { assert.throws(() => Resource[member], {name: "TypeError", message: `Method 'get' for property '${member}' not implemented by resource 'Resource'`}, - `Static member '${member}' not abstract`); + `Static member '${member}' was not abstract`); }); }); } @@ -38,13 +24,13 @@ describe("SCIMMY.Types.Resource", () => { describe(`.${method}()`, () => { it("should be defined", () => { assert.ok(typeof Resource[method] === "function", - `Abstract static method '${method}' not defined`); + `Static method '${method}' was not defined`); }); it("should be abstract", () => { assert.throws(() => Resource[method](), {name: "TypeError", message: `Method '${method}' not implemented by resource 'Resource'`}, - `Static method '${method}' not abstract`); + `Static method '${method}' was not abstract`); }); }); } @@ -52,14 +38,14 @@ describe("SCIMMY.Types.Resource", () => { describe(".extend()", () => { it("should be implemented", () => { assert.ok(typeof Resource.extend === "function", - "Static method 'extend' not implemented"); + "Static method 'extend' was not implemented"); }); }); describe(".describe()", () => { it("should be implemented", () => { assert.ok(typeof Resource.describe === "function", - "Static method 'describe' not implemented"); + "Static method 'describe' was not implemented"); }); const TestResource = createResourceClass(); @@ -71,13 +57,13 @@ describe("SCIMMY.Types.Resource", () => { for (let [prop, target = prop, expected = TestResource.schema.definition[target], suffix = ""] of properties) { it(`should expect '${prop}' property of description to equal '${target}' property of resource's schema definition${suffix}`, () => { assert.strictEqual(TestResource.describe()[prop], expected, - `Resource 'describe' method returned '${prop}' property with unexpected value`); + `Static method 'describe' returned '${prop}' property with unexpected value`); }); } it("should expect 'schemaExtensions' property to be excluded in description when resource is not extended", () => { assert.strictEqual(TestResource.describe().schemaExtensions, undefined, - "Resource 'describe' method unexpectedly included 'schemaExtensions' property in description"); + "Static method 'describe' unexpectedly included 'schemaExtensions' property in description"); }); it("should expect 'schemaExtensions' property to be included in description when resource is extended", function () { @@ -88,26 +74,26 @@ describe("SCIMMY.Types.Resource", () => { } assert.ok(!!TestResource.describe().schemaExtensions, - "Resource 'describe' method did not include 'schemaExtensions' property in description"); + "Static method 'describe' did not include 'schemaExtensions' property in description"); assert.deepStrictEqual(TestResource.describe().schemaExtensions, [{schema: "urn:ietf:params:scim:schemas:Extension", required: false}], - "Resource 'describe' method included 'schemaExtensions' property with unexpected value in description"); + "Static method 'describe' included 'schemaExtensions' property with unexpected value in description"); }); }); describe("#filter", () => { - it("should be an instance of SCIMMY.Types.Filter", () => { + it("should be an instance of Filter", () => { assert.ok(new Resource({filter: "userName eq \"Test\""}).filter instanceof Filter, - "Instance member 'filter' was not an instance of SCIMMY.Types.Filter"); + "Instance member 'filter' was not an instance of Filter"); }); }); describe("#attributes", () => { context("when 'excludedAttributes' query parameter was defined", () => { - it("should be an instance of SCIMMY.Types.Filter", () => { + it("should be an instance of Filter", () => { const resource = new Resource({excludedAttributes: "name"}); assert.ok(resource.attributes instanceof Filter, - "Instance member 'attributes' was not an instance of SCIMMY.Types.Filter"); + "Instance member 'attributes' was not an instance of Filter"); }); it("should expect filter expression to be 'not present'", () => { @@ -126,11 +112,11 @@ describe("SCIMMY.Types.Resource", () => { }); context("when 'attributes' query parameter was defined", () => { - it("should be an instance of SCIMMY.Types.Filter", () => { + it("should be an instance of Filter", () => { const resource = new Resource({attributes: "userName"}); assert.ok(resource.attributes instanceof Filter, - "Instance member 'attributes' was not an instance of SCIMMY.Types.Filter"); + "Instance member 'attributes' was not an instance of Filter"); }); it("should expect filter expression to be 'present'", () => { @@ -175,14 +161,23 @@ describe("SCIMMY.Types.Resource", () => { `Instance member 'constraints' was not an object when '${param}' query parameter was defined`); }); + it(`should include '${param}' property equal to '${param}' query parameter value when it was valid`, () => { + const resource = new Resource({[param]: validValue}); + + assert.strictEqual(resource.constraints[param], suite[param], + `Instance member 'constraints' did not include '${param}' property equal to '${param}' query parameter value`); + }); + for (let [label, value, validFor = []] of fixtures) if (!validFor.includes(param)) { it(`should not include '${param}' property when '${param}' query parameter had invalid ${label}`, () => { const resource = new Resource({[param]: value}); - + assert.ok(resource.constraints[param] === undefined, `Instance member 'constraints' included '${param}' property when '${param}' query parameter had invalid ${label}`); }); - + } + + for (let [label, value, validFor = []] of fixtures) if (!validFor.includes(param)) { it(`should include other valid properties when '${param}' query parameter had invalid ${label}`, () => { const resource = new Resource({...suite, [param]: value}); const expected = JSON.parse(JSON.stringify({...suite, [param]: undefined})); @@ -199,13 +194,13 @@ describe("SCIMMY.Types.Resource", () => { describe(`#${method}()`, () => { it("should be defined", () => { assert.ok(typeof (new Resource())[method] === "function", - `Abstract instance method '${method}' not defined`); + `Instance method '${method}' was not defined`); }); it("should be abstract", () => { assert.throws(() => new Resource()[method](), {name: "TypeError", message: `Method '${method}' not implemented by resource 'Resource'`}, - `Instance method '${method}' not abstract`); + `Instance method '${method}' was not abstract`); }); }); } diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index 8d873e9..cafc843 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,47 +1,31 @@ import assert from "assert"; import {Schema} from "#@/lib/types/schema.js"; -import {SchemaDefinition} from "#@/lib/types/definition.js"; - -/** - * Create a class that extends SCIMMY.Types.Schema, for use in tests - * @param {Object} [params] - parameters to pass through to the SchemaDefinition instance - * @param {String} [params.name] - the name to pass through to the SchemaDefinition instance - * @param {String} [params.id] - the ID to pass through to the SchemaDefinition instance - * @param {String} [params.description] - the description to pass through to the SchemaDefinition instance - * @param {String} [params.attributes] - the attributes to pass through to the SchemaDefinition instance - * @returns {typeof Schema} a class that extends SCIMMY.Types.Schema for use in tests - */ -export const createSchemaClass = ({name = "Test", id = "urn:ietf:params:scim:schemas:Test", description = "A Test", attributes} = {}) => ( - class Test extends Schema { - static #definition = new SchemaDefinition(name, id, description, attributes); - static get definition() { return Test.#definition; } - constructor(resource, direction = "both", basepath, filters) { - super(resource, direction); - Object.assign(this, Test.#definition.coerce(resource, direction, basepath, filters)); - } - } -); describe("SCIMMY.Types.Schema", () => { - it("should have abstract static member 'definition'", () => { - assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "definition").get === "function", - "Abstract static member 'definition' not defined"); - assert.throws(() => Schema.definition, - {name: "TypeError", message: "Method 'get' for property 'definition' must be implemented by subclass"}, - "Static member 'definition' not abstract"); + describe(".definition", () => { + it("should be defined", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "definition").get === "function", + "Static member 'definition' was not defined"); + }); + + it("should be abstract", () => { + assert.throws(() => Schema.definition, + {name: "TypeError", message: "Method 'get' for property 'definition' must be implemented by subclass"}, + "Static member 'definition' was not abstract"); + }); }); describe(".extend()", () => { - it("should have static method 'extend'", () => { + it("should be implemented", () => { assert.ok(typeof Schema.extend === "function", - "Static method 'extend' not defined"); + "Static method 'extend' was not implemented"); }); }); describe(".truncate()", () => { - it("should have static method 'truncate'", () => { + it("should be implemented", () => { assert.ok(typeof Schema.truncate === "function", - "Static method 'truncate' not defined"); + "Static method 'truncate' was not implemented"); }); }); }); \ No newline at end of file From bed6c61682b06c93227149cdf2e657fa8a740105 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 24 Apr 2023 13:02:31 +1000 Subject: [PATCH 76/93] Fix(SCIMMY.Types.SchemaDefinition): don't try modify read-only objects in coercion filtering --- src/lib/types/definition.js | 83 ++++++++++++++++++++----------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index c6fae99..af17bf0 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -298,7 +298,7 @@ export class SchemaDefinition { } } - return SchemaDefinition.#filter(target, {...filter}, this.attributes); + return SchemaDefinition.#filter(target, filter && {...filter}, this.attributes); } /** @@ -313,59 +313,68 @@ export class SchemaDefinition { // If there's no filter, just return the data if (filter === undefined) return data; // If the data is a set, only get values that match the filter - else if (Array.isArray(data)) return new Filter([filter]).match(data); + else if (Array.isArray(data)) + return data.map(data => SchemaDefinition.#filter(data, {...filter}, attributes)).filter(v => Object.keys(v).length); // Otherwise, filter the data! else { + // Prepare resultant value storage + const target = {}; + const inclusions = attributes.map(({name}) => name); + // Check for any negative filters for (let key in {...filter}) { // Find the attribute by lower case name const {name, config: {returned} = {}} = attributes.find(a => a.name.toLowerCase() === key.toLowerCase()) ?? {}; + // Mark the property as omitted from the result, and remove the spent filter if (returned !== "always" && Array.isArray(filter[key]) && filter[key][0] === "np") { - // Remove the property from the result, and remove the spent filter - delete data[name]; + inclusions.splice(inclusions.indexOf(name), 1); delete filter[key]; } } - // Check to see if there's any filters left - if (!Object.keys(filter).length) return data; - else { - // Prepare resultant value storage - const target = {} + // Check for remaining positive filters + if (Object.keys(filter).length) { + // If there was a positive filter, ignore the negative filters + inclusions.splice(0, inclusions.length); - // Go through every value in the data and filter attributes - for (let key in data) { - if (key.toLowerCase().startsWith("urn:")) { - // If there is data in a namespaced key, and a filter for it, include it - if (Object.keys(data[key]).length && Object.keys(filter).some(k => k.toLowerCase().startsWith(`${key.toLowerCase()}:`))) - target[key] = data[key]; - } else { - // Get the matching attribute definition and some relevant config values - let attribute = attributes.find(a => a.name === key) ?? {}, - {type, config: {returned, multiValued} = {}, subAttributes} = attribute; - - // If the attribute is always returned, add it to the result - if (returned === "always") target[key] = data[key]; - // Otherwise, if the attribute ~can~ be returned, process it - else if (returned === true) { - // If the filter is simply based on presence, assign the result - if (Array.isArray(filter[key]) && filter[key][0] === "pr") - target[key] = data[key]; - // Otherwise if the filter is defined and the attribute is complex, evaluate it - else if (key in filter && type === "complex") { - const value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); - - // Only set the value if it isn't empty - if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length)) - target[key] = value; - } + // Mark the positively filtered property as included in the result, and remove the spent filter + for (let key in {...filter}) if (Array.isArray(filter[key]) && filter[key][0] === "pr") { + inclusions.push(key); + delete filter[key]; + } + } + + // Go through every value in the data and filter attributes + for (let key in data) { + if (key.toLowerCase().startsWith("urn:")) { + // If there is data in a namespaced key, and a filter for it, include it + if (Object.keys(data[key]).length && inclusions.some(k => k.toLowerCase().startsWith(`${key.toLowerCase()}:`))) + target[key] = data[key]; + } else { + // Get the matching attribute definition and some relevant config values + const attribute = attributes.find(a => a.name === key) ?? {}; + const {type, config: {returned, multiValued} = {}, subAttributes} = attribute; + + // If the attribute is always returned, add it to the result + if (returned === "always") target[key] = data[key]; + // Otherwise, if the attribute was requested and ~can~ be returned, process it + else if (![false, "never"].includes(returned)) { + // If there was a simple presence filter for the attribute, assign it + if (inclusions.includes(key) && data[key] !== undefined) target[key] = data[key]; + // Otherwise, if there's an unhandled filter for a complex attribute, evaluate it + else if (key in filter && type === "complex") { + const value = SchemaDefinition.#filter(data[key], filter[key], subAttributes); + + // Only set the value if it isn't empty + if ((!multiValued && value !== undefined) || (Array.isArray(value) && value.length)) + target[key] = value; } } } - - return target; } + + return target; } } } \ No newline at end of file From 899e18a18edced88527f17ff1355186f290c20e1 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 24 Apr 2023 13:11:53 +1000 Subject: [PATCH 77/93] Tests(SCIMMY.Types.SchemaDefinition): coverage for filtering in coercion --- test/lib/types/definition.js | 138 +++++++++++++++++++++++++++-------- 1 file changed, 106 insertions(+), 32 deletions(-) diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 0712af4..0b402ad 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -2,6 +2,7 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import sinon from "sinon"; import SCIMMY from "#@/scimmy.js"; import {SchemaDefinition} from "#@/lib/types/definition.js"; import {Attribute} from "#@/lib/types/attribute.js"; @@ -128,7 +129,7 @@ describe("SCIMMY.Types.SchemaDefinition", () => { describe("#describe()", () => { it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).describe === "function", - "Instance method 'describe' was not defined"); + "Instance method 'describe' was not implemented"); }); it("should produce valid SCIM schema definition objects", async () => { @@ -149,7 +150,7 @@ describe("SCIMMY.Types.SchemaDefinition", () => { describe("#attribute()", () => { it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).attribute === "function", - "Instance method 'attribute' was not defined"); + "Instance method 'attribute' was not implemented"); }); it("should find attributes by name", () => { @@ -250,7 +251,7 @@ describe("SCIMMY.Types.SchemaDefinition", () => { describe("#extend()", () => { it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).extend === "function", - "Instance method 'extend' was not defined"); + "Instance method 'extend' was not implemented"); }); it("should expect 'extension' argument to be an instance of SchemaDefinition or collection of Attribute instances", () => { @@ -307,7 +308,7 @@ describe("SCIMMY.Types.SchemaDefinition", () => { describe("#truncate()", () => { it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).truncate === "function", - "Instance method 'truncate' was not defined"); + "Instance method 'truncate' was not implemented"); }); it("should do nothing without arguments", () => { @@ -375,7 +376,7 @@ describe("SCIMMY.Types.SchemaDefinition", () => { describe("#coerce()", () => { it("should be implemented", () => { assert.ok(typeof (new SchemaDefinition(...Object.values(params))).coerce === "function", - "Instance method 'coerce' was not defined"); + "Instance method 'coerce' was not implemented"); }); it("should expect 'data' argument to be an object", () => { @@ -403,37 +404,82 @@ describe("SCIMMY.Types.SchemaDefinition", () => { }); it("should expect coerce to be called on directly included attributes", () => { - const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [ - new Attribute("string", "test", {required: true}) - ]); + const stub = sinon.stub(); + const get = (t, p) => (p === "coerce" ? (...args) => (stub(...args) ?? t.coerce(...args)) : t[p]); + const attribute = new Proxy(new Attribute("string", "test", {required: true}), {get}); + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [attribute]); + // Make sure attribute 'coerce' method was called even if a value wasn't defined for it assert.throws(() => definition.coerce({}), {name: "TypeError", message: "Required attribute 'test' is missing"}, "Instance method 'coerce' did not attempt to coerce required attribute 'test'"); + assert.ok(stub.calledWithExactly(undefined, "both"), + "Instance method 'coerce' did not directly call attribute instance method 'coerce'"); + + // Make sure attribute 'coerce' method was called when a value was defined for it assert.throws(() => definition.coerce({test: false}), {name: "TypeError", message: "Attribute 'test' expected value type 'string' but found type 'boolean'"}, "Instance method 'coerce' did not reject boolean value 'false' for string attribute 'test'"); + assert.ok(stub.calledWithExactly(false, "both"), + "Instance method 'coerce' did not directly call attribute instance method 'coerce'"); + }); + + it("should expect coerce to be called on included schema extensions", () => { + const stub = sinon.stub(); + const get = (t, p) => (p === "coerce" ? (...args) => (stub(...args) ?? t.coerce(...args)) : t[p]); + const extensionId = params.id.replace("Test", "Extension"); + const attributes = [new Attribute("string", "employeeNumber")]; + const extension = new SchemaDefinition("Extension", extensionId, "An Extension", attributes).truncate(["schemas", "meta"]); + const definition = new SchemaDefinition(...Object.values(params)).extend(new Proxy(extension, {get}), true); + const {[extension.id]: actual} = definition.coerce({[`${extension.id}:employeeNumber`]: "1234"}); + const expected = {employeeNumber: "1234"}; + + assert.ok(stub.calledWithMatch({employeenumber: "1234"}), + "Instance method 'coerce' did not call coerce method on included schema extensions"); + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'coerce' did not correctly coerce included schema extension value"); + }); - it("should expect namespaced attributes or extensions to be coerced", () => { - const definition = new SchemaDefinition(...Object.values(params)) - .extend(SCIMMY.Schemas.EnterpriseUser.definition, true); + it("should expect required schema extensions to be defined", () => { + const extension = new SchemaDefinition("Extension", params.id.replace("Test", "Extension"), "An Extension", []); + const definition = new SchemaDefinition(...Object.values(params)).extend(extension, true); assert.throws(() => definition.coerce({}), - {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + {name: "TypeError", message: `Missing values for required schema extension '${extension.id}'`}, "Instance method 'coerce' did not attempt to coerce required schema extension"); - assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {}}), - {name: "TypeError", message: `Missing values for required schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + assert.throws(() => definition.coerce({[extension.id]: {}}), + {name: "TypeError", message: `Missing values for required schema extension '${extension.id}'`}, "Instance method 'coerce' did not attempt to coerce required schema extension"); - assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id]: {employeeNumber: "1234"}}), + }); + + it("should expect namespaced attributes or extensions to be coerced", () => { + const attribute = new Attribute("string", "employeeNumber"); + const extension = new SchemaDefinition("Extension", params.id.replace("Test", "Extension"), "An Extension", [attribute]); + const definition = new SchemaDefinition(...Object.values(params)).extend(extension, true); + const metadata = {schemas: [definition.id, extension.id], meta: {resourceType: definition.name}}; + const expected = {[extension.id]: {employeeNumber: "1234"}}; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(definition.coerce(expected))), {...metadata, ...expected}, "Instance method 'coerce' failed to coerce required schema extension value"); - assert.doesNotThrow(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: "1234"}), + assert.deepStrictEqual(JSON.parse(JSON.stringify(definition.coerce({[`${extension.id}:employeeNumber`]: "1234"}))), {...metadata, ...expected}, "Instance method 'coerce' failed to coerce required schema extension value"); - assert.throws(() => definition.coerce({[SCIMMY.Schemas.EnterpriseUser.definition.id + ":employeeNumber"]: false}), - {name: "TypeError", message: `Attribute 'employeeNumber' expected value type 'string' but found type 'boolean' in schema extension '${SCIMMY.Schemas.EnterpriseUser.definition.id}'`}, + assert.throws(() => definition.coerce({[`${extension.id}:employeeNumber`]: false}), + {name: "TypeError", message: `Attribute 'employeeNumber' expected value type 'string' but found type 'boolean' in schema extension '${extension.id}'`}, "Instance method 'coerce' did not attempt to coerce required schema extension's invalid value"); }); + it("should expect deeply nested namespaced and extension attributes to be merged and coerced", () => { + const attributes = [new Attribute("complex", "test", {}, [new Attribute("string", "name"), new Attribute("string", "value")])] + const extension = new SchemaDefinition("Extension", params.id.replace("Test", "Extension"), "An Extension", attributes); + const definition = new SchemaDefinition(...Object.values(params)).extend(extension, true); + const {[extension.id]: actual} = definition.coerce({[`${extension.id}:test.value`]: "Test", [extension.id]: {test: {name: "Test"}}}); + const expected = {test: {name: "Test", value: "Test"}}; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'coerce' did not expect deeply nested namespaced and extension attributes to be merged and coerced"); + }); + it("should expect the supplied filter to be applied to coerced result", () => { const attributes = [new Attribute("string", "testName"), new Attribute("string", "testValue")]; const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes); @@ -445,22 +491,50 @@ describe("SCIMMY.Types.SchemaDefinition", () => { "Instance method 'coerce' included attributes not specified for filter 'testName pr'"); }); + it("should expect negative filters to be applied to coerced results", () => { + const attributes = [new Attribute("complex", "test", {}, [new Attribute("string", "name"), new Attribute("string", "value")])]; + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes); + const actual = definition.coerce({test: {name: "Test", value: "False"}}, undefined, undefined, new Filter("test.value np")); + const expected = {test: {name: "Test"}} + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'coerce' did not expect negative filters to be applied to coerced results"); + }); + + it("should expect complex multi-valued attributes to be filtered positively", () => { + const attributes = [new Attribute("complex", "test", {multiValued: true}, [new Attribute("string", "name"), new Attribute("string", "value")])]; + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes); + const source = {test: [{name: "Test", value: "Test"}, {value: "False"}]}; + const actual = definition.coerce(source, undefined, undefined, new Filter("test[name pr]")); + const expected = {test: [{name: "Test"}]}; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'coerce' did not positively filter complex multi-valued attributes"); + }); + + it("should expect complex multi-valued attributes to be filtered negatively", () => { + const attributes = [new Attribute("complex", "test", {multiValued: true}, [new Attribute("string", "name"), new Attribute("string", "value")])]; + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", attributes); + const source = {test: [{name: "Test", value: "Test"}, {value: "False"}]}; + const actual = definition.coerce(source, undefined, undefined, new Filter("test[name np]")); + const expected = {test: [{value: "Test"}, {value: "False"}]}; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Instance method 'coerce' did not negatively filter complex multi-valued attributes"); + }); + it("should expect namespaced attributes in the supplied filter to be applied to coerced result", () => { - const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [new Attribute("string", "employeeNumber")]) - .extend(SCIMMY.Schemas.EnterpriseUser.definition); - const result = definition.coerce( - { - employeeNumber: "Test", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber": "1234", - "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:costCenter": "Test", - }, - undefined, undefined, - new Filter("urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:employeeNumber pr") - ); - - assert.strictEqual(result["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"].employeeNumber, "1234", + const attribute = new Attribute("string", "employeeNumber"); + const attributes = [new Attribute("string", "employeeNumber"), new Attribute("string", "costCenter")]; + const extension = new SchemaDefinition("Extension", params.id.replace("Test", "Extension"), "An Extension", attributes); + const definition = new SchemaDefinition(...Object.values(params), "Test Schema", [attribute]).extend(extension); + const source = {employeeNumber: "Test", [`${extension.id}:employeeNumber`]: "1234", [`${extension.id}:costCenter`]: "Test"}; + const actual = definition.coerce(source, undefined, undefined, new Filter(`${extension.id}:employeeNumber pr`)); + const expected = {[extension.id]: {employeeNumber: "1234"}}; + + assert.strictEqual(actual[extension.id].employeeNumber, "1234", "Instance method 'coerce' did not include namespaced attributes for filter"); - assert.ok(!Object.keys(result).includes("testName"), + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'coerce' included namespaced attributes not specified for filter"); }); }); From fe7066aa028a4a8124a14acf8f9970a4f1a3e6cb Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 24 Apr 2023 14:00:21 +1000 Subject: [PATCH 78/93] Tests(SCIMMY.Types.Attribute): coverage for 'binary' type coercion --- test/lib/types/attribute.js | 35 +++++++++++++++++++++++++++++++++++ test/lib/types/definition.js | 1 - 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index 4d520d1..62af3ed 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -426,6 +426,41 @@ describe("SCIMMY.Types.Attribute", () => { }); }); + it("should expect value to be a base64 encoded string when attribute type is 'binary'", () => ( + typedCoercion("binary", { + assertion: (type) => (["complex", "dateTime"].includes(type) ? ( + `Attribute 'test' expected value type 'binary' but found type '${type}'` + ) : ( + "Attribute 'test' expected value type 'binary' to be base64 encoded string or binary octet stream" + )), + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + + it("should expect all values to be base64 encoded strings when attribute is multi-valued and type is 'binary'", () => ( + typedCoercion("binary", { + multiValued: true, + assertion: (type) => (["complex", "dateTime"].includes(type) ? ( + `Attribute 'test' expected value type 'binary' but found type '${type}'` + ) : ( + "Attribute 'test' expected value type 'binary' to be base64 encoded string or binary octet stream" + )), + valid: [["string value 'a string'", "a string"]], + invalid: [ + ["number value '1'", "number", 1], + ["complex value", "complex", {}], + ["boolean value 'false'", "boolean", false], + ["Date instance value", "dateTime", new Date()] + ] + }) + )); + it("should expect value to be an object when attribute type is 'complex'", () => ( typedCoercion("complex", { assertion: (type) => `Complex attribute 'test' expected complex value but found type '${type}'`, diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 0b402ad..031a6ab 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -438,7 +438,6 @@ describe("SCIMMY.Types.SchemaDefinition", () => { "Instance method 'coerce' did not call coerce method on included schema extensions"); assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, "Instance method 'coerce' did not correctly coerce included schema extension value"); - }); it("should expect required schema extensions to be defined", () => { From 0160e2ca1610ff9753934a65c930852834152c6a Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 24 Apr 2023 14:14:00 +1000 Subject: [PATCH 79/93] Tests(SCIMMY.Types.Attribute->#coerce()): coverage for unknown types --- test/lib/types/attribute.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index 62af3ed..3aa2d6f 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -191,6 +191,16 @@ describe("SCIMMY.Types.Attribute", () => { "Instance method 'coerce' was not defined"); }); + it("should do nothing when 'type' is unrecognised", () => { + const target = new Attribute("string", "test"); + const get = (t, p) => (p === "type" ? "test" : target[p]); + const attribute = new Proxy({}, {get}); + const source = {}; + + assert.strictEqual(attribute.coerce(source), source, + "Instance method 'coerce' did not do nothing when 'type' was unrecognised"); + }); + it("should expect required attributes to have a value", () => { for (let type of ["string", "complex", "boolean", "binary", "decimal", "integer", "dateTime", "reference"]) { assert.throws(() => new Attribute(type, "test", {required: true}).coerce(), From 97ff6d2927e22fa8fcea0c487baba8c6cb334af3 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Mon, 24 Apr 2023 14:41:56 +1000 Subject: [PATCH 80/93] Tests(SCIMMY.Types.Attribute->#coerce()): coverage for complex subAttribute wrapping --- test/lib/types/attribute.js | 84 +++++++++++++++++++++++++++++-------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/test/lib/types/attribute.js b/test/lib/types/attribute.js index 3aa2d6f..53d3231 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -29,14 +29,19 @@ export const instantiateFromFixture = ({type, name, mutability: m, uniqueness: u * @param {[string, any][]} [fixture.valid=[]] - list of valid coercion inputs to verify * @param {[string, string, any][]} [fixture.invalid=[]] - list of invalid coercion inputs to verify * @param {Function} [fixture.assertion] - function to call, with invalid input data type, to get expected assertion message + * @param {Attribute[]} [fixture.subAttributes] - list of subAttributes to add to the attribute definition */ -function typedCoercion(type, {config = {}, multiValued = false, valid= [], invalid = [], assertion} = {}) { - const attribute = new Attribute(type, "test", {...config, multiValued}); +function typedCoercion(type, {config = {}, multiValued = false, valid= [], invalid = [], assertion, subAttributes} = {}) { + const attribute = new Attribute(type, "test", {...config, multiValued}, subAttributes); const target = (multiValued ? attribute.coerce([]) : null); for (let [label, value] of valid) { - assert.doesNotThrow(() => (multiValued ? target.push(value) : attribute.coerce(value)), - `Instance method 'coerce' rejected ${label} when attribute type was ${type}`); + try { + if (multiValued) target.push(value) + else attribute.coerce(value); + } catch { + assert.fail(`Instance method 'coerce' rejected ${label} when attribute type was ${type}`) + } } for (let [label, actual, value] of invalid) { @@ -218,8 +223,12 @@ describe("SCIMMY.Types.Attribute", () => { it("should expect value to be an array when attribute is multi-valued", () => { const attribute = new Attribute("string", "test", {multiValued: true}); - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty value when attribute was not required"); + try { + attribute.coerce(); + } catch { + assert.fail("Instance method 'coerce' rejected empty value when attribute was not required"); + } + assert.ok(Array.isArray(attribute.coerce([])), "Instance method 'coerce' did not produce array when attribute was multi-valued and value was array"); assert.throws(() => attribute.coerce("a string"), @@ -233,8 +242,6 @@ describe("SCIMMY.Types.Attribute", () => { it("should expect value to be singular when attribute is not multi-valued", () => { const attribute = new Attribute("string", "test"); - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty value when attribute was not required"); assert.throws(() => attribute.coerce(["a string"]), {name: "TypeError", message: "Attribute 'test' is not multi-valued and must not be a collection"}, "Instance method 'coerce' did not reject array value ['a string']"); @@ -246,27 +253,33 @@ describe("SCIMMY.Types.Attribute", () => { it("should expect value to be canonical when attribute specifies canonicalValues characteristic", () => { const attribute = new Attribute("string", "test", {canonicalValues: ["Test"]}); - assert.doesNotThrow(() => attribute.coerce(), - "Instance method 'coerce' rejected empty non-canonical value"); + try { + attribute.coerce("Test"); + } catch { + assert.fail("Instance method 'coerce' rejected canonical value 'Test'"); + } + assert.throws(() => attribute.coerce("a string"), {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, "Instance method 'coerce' did not reject non-canonical value 'a string'"); - assert.doesNotThrow(() => attribute.coerce("Test"), - "Instance method 'coerce' rejected canonical value 'Test'"); }); it("should expect all values to be canonical when attribute is multi-valued and specifies canonicalValues characteristic", () => { const attribute = new Attribute("string", "test", {multiValued: true, canonicalValues: ["Test"]}); const target = attribute.coerce([]); + try { + attribute.coerce(["Test"]); + } catch { + assert.fail("Instance method 'coerce' rejected canonical value 'Test'"); + } + assert.throws(() => attribute.coerce(["a string"]), {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, "Instance method 'coerce' did not reject non-canonical value 'a string' by itself"); assert.throws(() => attribute.coerce(["Test", "a string"]), {name: "TypeError", message: "Attribute 'test' contains non-canonical value"}, `Instance method 'coerce' did not reject non-canonical value 'a string' with canonical value 'Test'`); - assert.doesNotThrow(() => attribute.coerce(["Test"]), - "Instance method 'coerce' rejected canonical value 'Test'"); assert.throws(() => target.push("a string"), {name: "TypeError", message: "Attribute 'test' does not include canonical value 'a string'"}, "Instance method 'coerce' did not reject addition of non-canonical value 'a string' to coerced collection"); @@ -407,8 +420,12 @@ describe("SCIMMY.Types.Attribute", () => { "Instance method 'coerce' did not expect reference value when attribute type was reference"); typedCoercion("reference", { - config: {referenceTypes: ["uri", "external"]}, - valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], + config: {referenceTypes: ["uri", "external", "Test"]}, + valid: [ + ["external reference value", "https://example.com"], + ["URI reference value", "urn:ietf:params:scim:schemas:Test"], + ["ResourceType reference value", "Test"] + ], invalid: [ ["number value '1'", "number", 1], ["boolean value 'false'", "boolean", false], @@ -425,8 +442,12 @@ describe("SCIMMY.Types.Attribute", () => { typedCoercion("reference", { multiValued: true, - config: {referenceTypes: ["uri", "external"]}, - valid: [["external reference value", "https://example.com"], ["URI reference value", "urn:ietf:params:scim:schemas:Test"]], + config: {referenceTypes: ["uri", "external", "Test"]}, + valid: [ + ["external reference value", "https://example.com"], + ["URI reference value", "urn:ietf:params:scim:schemas:Test"], + ["ResourceType reference value", "Test"] + ], invalid: [ ["number value '1'", "number", 1], ["boolean value 'false'", "boolean", false], @@ -497,5 +518,32 @@ describe("SCIMMY.Types.Attribute", () => { ] }) )); + + it("should expect subAttribute values to be wrapped when attribute type is 'complex'", () => { + typedCoercion("complex", { + subAttributes: [new Attribute("string", "test")], + assertion: (type) => `Attribute 'test' expected value type 'string' but found type '${type}' from complex attribute 'test'`, + valid: [["complex value", {test: "a string"}]], + invalid: [ + ["complex value '{test: 1}'", "number", {test: 1}], + ["complex value '{test: true}'", "boolean", {test: true}], + ["complex value '{test: new Date()}'", "dateTime", {test: new Date()}] + ] + }) + }); + + it("should expect all subAttribute values to be wrapped when attribute is multi-valued and type is 'complex'", () => { + typedCoercion("complex", { + multiValued: true, + subAttributes: [new Attribute("string", "test")], + assertion: (type) => `Attribute 'test' expected value type 'string' but found type '${type}' from complex attribute 'test'`, + valid: [["complex value", {test: "a string"}]], + invalid: [ + ["complex value '{test: 1}'", "number", {test: 1}], + ["complex value '{test: true}'", "boolean", {test: true}], + ["complex value '{test: new Date()}'", "dateTime", {test: new Date()}] + ] + }) + }); }); }); \ No newline at end of file From f099988ecc24bfa8ab6804ee69b8ed7e7124a1e9 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 27 Apr 2023 16:24:49 +1000 Subject: [PATCH 81/93] Tests(SCIMMY.Schemas): add coverage for extension attribute cleanup --- test/hooks/schemas.js | 82 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/test/hooks/schemas.js b/test/hooks/schemas.js index 47606b5..d44c04d 100644 --- a/test/hooks/schemas.js +++ b/test/hooks/schemas.js @@ -40,7 +40,7 @@ export default { it("should validate 'schemas' property of 'resource' parameter if it is defined", () => { try { // Add an empty required extension - TargetSchema.extend(new SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test"), true); + TargetSchema.extend(new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension"), true); assert.throws(() => new TargetSchema({schemas: ["a string"]}), {name: "SCIMError", status: 400, scimType: "invalidSyntax", @@ -48,11 +48,11 @@ export default { "Schema instance did not validate 'schemas' property of 'resource' parameter"); assert.throws(() => new TargetSchema({schemas: [TargetSchema.definition.id]}), {name: "SCIMError", status: 400, scimType: "invalidValue", - message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Test' required by this resource type"}, + message: "The request body is missing schema extension 'urn:ietf:params:scim:schemas:Extension' required by this resource type"}, "Schema instance did not validate required extensions in 'schemas' property of 'resource' parameter"); } finally { // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + TargetSchema.truncate("urn:ietf:params:scim:schemas:Extension"); } }); @@ -88,11 +88,11 @@ export default { it("should include extension schema attribute property accessor aliases", async () => { try { // Add an extension with one attribute - TargetSchema.extend(new SchemaDefinition("Test", "urn:ietf:params:scim:schemas:Test", "", [new Attribute("string", "testValue")])); + TargetSchema.extend(new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", [new Attribute("string", "testValue")])); // Construct an instance to test against const {constructor = {}} = await fixtures; - const target = "urn:ietf:params:scim:schemas:Test:testValue"; + const target = "urn:ietf:params:scim:schemas:Extension:testValue"; const instance = new TargetSchema(constructor); instance[target] = "a string"; @@ -103,7 +103,72 @@ export default { "Schema instance did not include lower-case schema extension attribute aliases"); } finally { // Remove the extension so it doesn't interfere later - TargetSchema.truncate("urn:ietf:params:scim:schemas:Test"); + TargetSchema.truncate("urn:ietf:params:scim:schemas:Extension"); + } + }); + + it("should expect errors in extension schema coercion to be rethrown as SCIMErrors", async () => { + const attributes = [new Attribute("string", "testValue")]; + const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); + const {constructor = {}} = await fixtures; + const source = {...constructor, [`${extension.id}:testValue`]: "a string"}; + + try { + // Add the extension to the target + TargetSchema.extend(extension); + + // Construct an instance to test against + const instance = new TargetSchema(source); + + assert.throws(() => instance[extension.id].test = true, + {name: "TypeError", message: "Cannot add property test, object is not extensible"}, + "Schema was extensible after instantiation"); + assert.throws(() => instance[extension.id] = {test: true}, + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Cannot add property test, object is not extensible"}, + "Schema was extensible after instantiation"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Extension"); + } + }); + + it("should clean up empty extension schema properties", async () => { + // Get attributes for the extension ready + const attributes = [ + new Attribute("complex", "testValue", {}, [ + new Attribute("string", "stringValue"), + new Attribute("complex", "value", {}, [ + new Attribute("string", "value") + ]) + ]) + ]; + + // Get the extension and the source data ready + const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); + const {constructor = {}} = await fixtures; + const source = { + ...constructor, + [`${extension.id}:testValue.stringValue`]: "a string", + [`${extension.id}:testValue.value.value`]: "a string" + }; + + try { + // Add the extension to the target + TargetSchema.extend(extension); + + // Construct an instance to test against + const instance = new TargetSchema(source); + + // Unset the extension value and check for cleanup + instance[`${extension.id}:testValue.value.value`] = undefined; + instance[`${extension.id}:testValue.stringValue`] = undefined; + + assert.strictEqual(instance[extension.id], undefined, + "Schema instance did not clean up empty extension schema properties"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Extension"); } }); @@ -120,9 +185,12 @@ export default { }); }), definition: (TargetSchema, fixtures) => (() => { - it("should have static member 'definition' that is an instance of SchemaDefinition", () => { + it("should be defined", () => { assert.ok("definition" in TargetSchema, "Static member 'definition' not defined"); + }); + + it("should be an instance of SchemaDefinition", () => { assert.ok(TargetSchema.definition instanceof SchemaDefinition, "Static member 'definition' was not an instance of SchemaDefinition"); }); From 08267bab8fefdfe1963410d267d5ade0fe3271a5 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 27 Apr 2023 16:26:37 +1000 Subject: [PATCH 82/93] Fix(SCIMMY.Types.Schema): support namepath access of complex extension schema attributes --- src/lib/types/schema.js | 117 +++++++++++++++++++++++++++------------- 1 file changed, 79 insertions(+), 38 deletions(-) diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js index cff0df1..8473bd8 100644 --- a/src/lib/types/schema.js +++ b/src/lib/types/schema.js @@ -2,6 +2,14 @@ import {SchemaDefinition} from "./definition.js"; import {Attribute} from "./attribute.js"; import {SCIMError} from "./error.js"; +/** + * Deeply check whether a targeted object has any properties with actual values + * @param {Object} target - object to deeply check for values + * @returns {Boolean} whether the target object, or any of its object properties, have a value other than undefined + * @private + */ +const hasActualValues = (target) => (Object.values(target).some((v) => typeof v === "object" ? hasActualValues(v) : v !== undefined)); + /** * SCIM Schema Type * @alias SCIMMY.Types.Schema @@ -33,6 +41,10 @@ export class Schema { * @param {Boolean} [required=false] - if the extension is a schema, whether the extension is required */ static extend(extension, required = false) { + if (!(extension instanceof SchemaDefinition) && !(extension?.prototype instanceof Schema) + && !(Array.isArray(extension) ? extension : [extension]).every(e => e instanceof Attribute)) + throw new TypeError("Expected 'extension' to be a Schema class, SchemaDefinition instance, or collection of Attribute instances"); + this.definition.extend((extension.prototype instanceof Schema ? extension.definition : extension), required); } @@ -50,13 +62,13 @@ export class Schema { * @param {String} [direction="both"] - whether the resource is inbound from a request or outbound for a response */ constructor(data = {}, direction) { - let {schemas = []} = data, - // Create internally scoped storage object - resource = {}, - // Source attributes and extensions from schema definition - {definition} = this.constructor, - attributes = definition.attributes.filter(a => a instanceof Attribute), - extensions = definition.attributes.filter(a => a instanceof SchemaDefinition); + const {schemas = []} = data; + // Create internally scoped storage object + const resource = {}; + // Source attributes and extensions from schema definition + const {definition} = this.constructor; + const attributes = definition.attributes.filter(a => a instanceof Attribute); + const extensions = definition.attributes.filter(a => a instanceof SchemaDefinition); // If schemas attribute is specified, make sure all required schema IDs are present if (Array.isArray(schemas) && schemas.length) { @@ -72,6 +84,9 @@ export class Schema { } } + // Save the directionality of this instance to a symbol for use elsewhere + Object.defineProperty(this, Symbol.for("direction"), {value: direction}); + // Predefine getters and setters for all possible attributes for (let attribute of attributes) Object.defineProperties(this, { // Because why bother with case-sensitivity in a JSON-based standard? @@ -87,7 +102,7 @@ export class Schema { // Get and set the value from the internally scoped object get: () => (resource[attribute.name]), set: (value) => { - let {name, config: {mutable}} = attribute; + const {name, config: {mutable}} = attribute; // Check for mutability of attribute before setting the value if (mutable !== true && this[name] !== undefined && this[name] !== value) @@ -116,29 +131,22 @@ export class Schema { enumerable: true, // Get and set the value from the internally scoped object get: () => { - // Do some cleanup if the extension actually has a value - if (resource[extension.id] !== undefined) { - let target = resource[extension.id]; - - for (let key of Object.keys(target)) { - // Go through and delete any undefined properties or complex attributes without actual values - if (target[key] === undefined || (Object(target[key]) === target[key] - && !Object.keys(target[key]).some(k => target[key][k] !== undefined))) { - delete target[key]; - } + // Go through and delete any undefined properties or complex attributes without actual values + for (let [key, value] of Object.entries(resource[extension.id] ?? {})) { + if (value === undefined || (Object(value) === value && !hasActualValues(value))) { + delete resource[extension.id][key]; } - - // If no attributes with values remaining, delete the extension namespace from the instance - if (!Object.keys(resource[extension.id]).some(k => resource[extension.id][k] !== undefined)) - delete resource[extension.id]; } - - return resource[extension.id]; + + // If no attributes with values remaining, return undefined + return !hasActualValues(resource[extension.id] ?? {}) ? undefined : resource[extension.id]; }, set: (value) => { try { // Validate the supplied value through schema extension coercion - return (resource[extension.id] = extension.coerce(value, direction)) && resource[extension.id]; + resource[extension.id] = extension.coerce(value, direction); + + return Object.assign(Object.preventExtensions(resource[extension.id]), value); } catch (ex) { // Rethrow attribute coercion exceptions as SCIM errors throw new SCIMError(400, "invalidValue", ex.message); @@ -146,19 +154,52 @@ export class Schema { } }, // Predefine namespaced getters and setters for schema extension attributes - ...extension.attributes.reduce((definitions, attribute) => Object.assign(definitions, { - // Lower-case getter/setter aliases to work around case sensitivity, as above - [`${extension.id.toLowerCase()}:${attribute.name.toLowerCase()}`]: { - get: () => (this[`${extension.id}:${attribute.name}`]), - set: (value) => (this[`${extension.id}:${attribute.name}`] = value) - }, - // Proper-case namespaced extension attributes - [`${extension.id}:${attribute.name}`]: { - get: () => (this[extension.id]?.[attribute.name]), - // Trigger setter for the actual schema extension property - set: (value) => (this[extension.id] = Object.assign(this[extension.id] ?? {}, {[attribute.name]: value})) - } - }), {}) + ...extension.attributes.reduce((() => { + const getExtensionReducer = (path = "") => (definitions, attribute) => Object.assign(definitions, { + // Lower-case getter/setter aliases to work around case sensitivity, as above + [`${extension.id}:${path}${attribute.name}`.toLowerCase()]: { + get: () => (this[`${extension.id}:${path}${attribute.name}`]), + set: (value) => (this[`${extension.id}:${path}${attribute.name}`] = value) + }, + // Proper-case namespaced extension attributes + [`${extension.id}:${path}${attribute.name}`]: { + get: () => { + // Get the underlying nested path of the attribute + const paths = path.replace(/([.])$/, "").split(".").filter(p => !!p); + let target = this[extension.id]; + + // Go through the attribute path on the extension to find the actual target + while (paths.length) target = target?.[paths.shift()]; + + return target?.[attribute.name]; + }, + // Trigger setter for the actual schema extension property + set: (value) => { + // Get the underlying nested path of the attribute, and a copy of the data to set + const paths = path.replace(/([.])$/, "").split(".").filter(p => !!p); + let target = {...this[extension.id]}, data = target; + + // Go through the attribute path on the extension... + while (paths.length) { + const path = paths.shift(); + + // ...and set any missing container paths along the way + target = target[path] = {...(target?.[path] ?? {})}; + } + + // Set the actual value + target[attribute.name] = value; + + // Then assign it back to the extension for coercion + return (this[extension.id] = Object.assign(this[extension.id] ?? {}, data)); + } + }, + // Go through the process again for subAttributes + ...(attribute.subAttributes ? attribute.subAttributes.reduce(getExtensionReducer(`${path}${attribute.name}.`), {}) : {}) + }); + + return getExtensionReducer(); + })(), {}) }); // Prevent attributes from being added or removed From 4d60b995d589ce3df0a45ab604aa5be009a9bef7 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 27 Apr 2023 16:28:06 +1000 Subject: [PATCH 83/93] Add(SCIMMY.Types.{Attribute,Schema}): toJSON filtering of unreturned attributes --- src/lib/types/attribute.js | 7 +++++++ src/lib/types/schema.js | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index bbf0880..6c49094 100644 --- a/src/lib/types/attribute.js +++ b/src/lib/types/attribute.js @@ -548,6 +548,13 @@ export class Attribute { }); } + // Set "toJSON" method on target so subAttributes can be filtered + Object.defineProperty(target, "toJSON", { + value: () => Object.entries(resource) + .filter(([name]) => ![false, "never"].includes(this.subAttributes.find(a => a.name === name).config.returned)) + .reduce((res, [name, value]) => Object.assign(res, {[name]: value}), {}) + }); + // Prevent changes to target Object.freeze(target); diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js index 8473bd8..28081a9 100644 --- a/src/lib/types/schema.js +++ b/src/lib/types/schema.js @@ -2,6 +2,20 @@ import {SchemaDefinition} from "./definition.js"; import {Attribute} from "./attribute.js"; import {SCIMError} from "./error.js"; +/** + * Define the "toJSON" property for the given target + * @param {Object} target - the object to define the "toJSON" property on + * @param {SchemaDefinition} definition - the schema definition associated with the target + * @param {Object} resource - the underlying resource associated with the target + * @returns {Object} the original target object, with the "toJSON" property defined + * @private + */ +const defineToJSONProperty = (target, definition, resource) => Object.defineProperty(target, "toJSON", { + value: () => Object.entries(resource) + .filter(([name]) => ![false, "never"].includes(definition.attribute(name)?.config?.returned)) + .reduce((res, [name, value]) => Object.assign(res, {[name]: value}), {}) +}); + /** * Deeply check whether a targeted object has any properties with actual values * @param {Object} target - object to deeply check for values @@ -86,6 +100,8 @@ export class Schema { // Save the directionality of this instance to a symbol for use elsewhere Object.defineProperty(this, Symbol.for("direction"), {value: direction}); + // Set "toJSON" method on self so attributes can be filtered + defineToJSONProperty(this, definition, resource); // Predefine getters and setters for all possible attributes for (let attribute of attributes) Object.defineProperties(this, { @@ -146,6 +162,8 @@ export class Schema { // Validate the supplied value through schema extension coercion resource[extension.id] = extension.coerce(value, direction); + // Return the value with JSON stringifier attached, marked as + defineToJSONProperty(resource[extension.id], extension, resource[extension.id]); return Object.assign(Object.preventExtensions(resource[extension.id]), value); } catch (ex) { // Rethrow attribute coercion exceptions as SCIM errors From 707d31349065c0bd446737d048e1ea022ed44ceb Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Thu, 27 Apr 2023 16:28:34 +1000 Subject: [PATCH 84/93] Tests(SCIMMY.Types.Schema): coverage for toJSON filtering method --- test/lib/types/schema.js | 24 ++++++++++++++++++++++++ test/lib/types/schema.json | 10 ++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/lib/types/schema.json diff --git a/test/lib/types/schema.js b/test/lib/types/schema.js index cafc843..de449c0 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,6 +1,15 @@ +import {promises as fs} from "fs"; +import path from "path"; +import url from "url"; import assert from "assert"; +import SchemasHooks, {createSchemaClass} from "../../hooks/schemas.js"; +import {Attribute} from "#@/lib/types/attribute.js"; import {Schema} from "#@/lib/types/schema.js"; +// Load data to use in tests from adjacent JSON file +const basepath = path.relative(process.cwd(), path.dirname(url.fileURLToPath(import.meta.url))); +const fixtures = fs.readFile(path.join(basepath, "./schema.json"), "utf8").then((f) => JSON.parse(f)); + describe("SCIMMY.Types.Schema", () => { describe(".definition", () => { it("should be defined", () => { @@ -28,4 +37,19 @@ describe("SCIMMY.Types.Schema", () => { "Static method 'truncate' was not implemented"); }); }); + + describe("@constructor", () => { + SchemasHooks.construct(createSchemaClass({attributes: [new Attribute("string", "aString")]}), fixtures).call(); + + it("should include 'toJSON' method that strips attributes where returned is marked as 'never'", async () => { + const attributes = [new Attribute("string", "aValue"), new Attribute("string", "aString", {returned: false})]; + const Test = createSchemaClass({attributes}); + const source = {aValue: "a value"}; + const actual = new Test({...source, aString: "a string"}); + const expected = {schemas: [Test.definition.id], meta: {resourceType: Test.definition.name}, ...source}; + + assert.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "Schema instance did not include 'toJSON' method that strips attributes where returned is marked as 'never'"); + }); + }); }); \ No newline at end of file diff --git a/test/lib/types/schema.json b/test/lib/types/schema.json new file mode 100644 index 0000000..0e0add9 --- /dev/null +++ b/test/lib/types/schema.json @@ -0,0 +1,10 @@ +{ + "constructor": {"aString": "Test"}, + "definition": { + "name": "Test", + "id": "urn:ietf:params:scim:schemas:Test", + "attributes": [ + {"name": "aString", "type": "string"} + ] + } +} \ No newline at end of file From acfe296a20ce2bd244a70fc79b19705412d739c3 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 23 May 2023 17:34:38 +1000 Subject: [PATCH 85/93] Fix(SCIMMY.Types.Filter): handling of case-sensitive operators and comparators --- src/lib/types/filter.js | 49 +++++++++++++++++++++++++------------- test/lib/types/filter.json | 14 +++++++---- 2 files changed, 42 insertions(+), 21 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 218f33c..810aba4 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -96,13 +96,13 @@ export class Filter extends Array { if (result === false) break; // Check for negation and extract the comparator and expected values - const negate = (expression[0] === "not"); + const negate = (expression[0].toLowerCase() === "not"); let [comparator, expected] = expression.slice(((+negate) - expression.length)); // Cast true and false strings to boolean values expected = (expected === "false" ? false : (expected === "true" ? true : expected)); - switch (comparator) { + switch (comparator.toLowerCase()) { default: result = false; break; @@ -197,8 +197,23 @@ export class Filter extends Array { if (tokens.length && tokens[tokens.length-1].type === "Word" && tokens[tokens.length-1].value.endsWith(".")) word = tokens.pop().value + word; - // Store the token, deriving token type by matching against known operators and comparators - tokens.push({type: (operators.includes(word) ? "Operator" : (comparators.includes(word) ? "Comparator" : "Word")), value: word}); + // Derive the token's type by matching against known operators and comparators + let type = (operators.includes(word.toLowerCase()) ? "Operator" : (comparators.includes(word.toLowerCase()) ? "Comparator" : "Word")); + + // If there was a previous token, make sure it was accurate + if (tokens.length) { + const previous = tokens[tokens.length-1]; + + // If the previous token was also a comparator, it may have actually been a word + if (previous.type === "Comparator" && type === "Comparator") previous.type = "Word"; + // If the previous token was also an operator... + if (previous.type === "Operator" && type === "Operator" + // ...and that operator was "not", or this operator is NOT "not", it may have been a word + && (previous.value.toLowerCase() === "not" || word.toLowerCase() !== "not")) type = "Word"; + } + + // Store the token + tokens.push({type, value: word}); } } @@ -234,7 +249,7 @@ export class Filter extends Array { for (let token of [...tokens]) { // Found the target operator token, push preceding tokens as an operation - if (token.type === "Operator" && token.value === operator) + if (token.type === "Operator" && token.value.toLowerCase() === operator) operations.push(tokens.splice(0, tokens.indexOf(token) + 1).slice(0, -1)); // Reached the end, add the remaining tokens as an operation else if (tokens.indexOf(token) === tokens.length - 1) @@ -256,7 +271,7 @@ export class Filter extends Array { // Go through every expression in the list, or handle a singular expression if that's what was given for (let expression of (expressions.every(e => Array.isArray(e)) ? expressions : [expressions])) { // Check if first token is negative for later evaluation - const negative = expression[0] === "not" ? expression.shift() : undefined; + const negative = (expression.length === 4 ? expression.shift() : undefined)?.toLowerCase?.(); // Extract expression parts and derive object path const [path, comparator, expected] = expression; const parts = path.split(pathSeparator).filter(p => p); @@ -273,7 +288,7 @@ export class Filter extends Array { else { // Unwrap string and null values, and store the translated expression value = (value === "null" ? null : (String(value).match(/^["].*["]$/) ? value.substring(1, value.length - 1) : value)); - const expression = [negative, comparator, value].filter(v => v !== undefined); + const expression = [negative, comparator.toLowerCase(), value].filter(v => v !== undefined); // Either store the single expression, or convert to array if attribute already has an expression defined target[name] = (!Array.isArray(target[name]) ? expression : [...(target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]); @@ -301,7 +316,7 @@ export class Filter extends Array { // If there's no operators or groups, and no nested attribute filters, assume the expression is complete if (reallySimple) { - results.push(Array.isArray(query) ? tokens.map(t => t.value ?? t) : Filter.#objectify(tokens.splice(0).map(t => t.value ?? t))); + results.push(Array.isArray(query) ? tokens.map(t => t.value ?? t) : Filter.#objectify(tokens.splice(0).map(t => t?.value ?? t))); } // Otherwise, logic and groups need to be evaluated else { @@ -318,13 +333,13 @@ export class Filter extends Array { // Go through every expression and check for nested attribute filters for (let e of expression.splice(0)) { // Check if first token is negative for later evaluation - let negative = e[0].value === "not" ? e.shift() : undefined, - // Extract expression parts and derive object path - [path, comparator, value] = e; + const negative = e[0].type === "Operator" && e[0].value.toLowerCase() === "not" ? e.shift() : undefined; + // Extract expression parts and derive object path + const [path, comparator, value] = e; // If none of the path parts have multi-value filters, put the expression back on the stack if (path.value.split(pathSeparator).filter(p => p).every(t => t === multiValuedFilter.exec(t).slice(1).shift())) { - expression.push([negative, path, comparator, value].filter(v => v !== undefined)); + expression.push([negative, path, comparator, value]); } // Otherwise, delve into the path parts for complexities else { @@ -349,10 +364,10 @@ export class Filter extends Array { .map(b => b.every(b => b.every(b => Array.isArray(b))) ? b.flat(1) : b) // Prefix any attribute paths with spent parts .map((branch) => branch.map(join => { - const negative = (join[0] === "not" ? join.shift() : undefined); + const negative = (join.length === 4 || (join.length === 3 && comparators.includes(join[join.length-1].toLowerCase())) ? join.shift() : undefined); const [path, comparator, value] = join; - return [negative, `${spent.join(".")}.${path}`, comparator, value].filter(v => v !== undefined); + return [negative?.toLowerCase?.(), `${spent.join(".")}.${path}`, comparator, value]; })); if (!results.length) { @@ -371,7 +386,7 @@ export class Filter extends Array { } // No filter, but if we're at the end of the chain, join the last expression with the results else if (parts.indexOf(part) === parts.length - 1) { - for (let result of results) result.push([negative?.value, spent.join("."), comparator?.value, value?.value].filter(v => v !== undefined)); + for (let result of results) result.push([negative?.value, spent.join("."), comparator?.value, value?.value]); } } @@ -403,7 +418,7 @@ export class Filter extends Array { for (let token of (expression.length ? expression : [[]])) { for (let branch of branches) { groups.push([ - ...(token.length ? [token.map(t => t.value ?? t)] : []), + ...(token.length ? [token.map(t => t?.value ?? t)] : []), ...(group.length ? group : []), ...Filter.#parse(branch) ]); @@ -416,7 +431,7 @@ export class Filter extends Array { // Consider each group its own expression if (groups.length) expressions.push(...groups); // Otherwise, collapse the expression for potential objectification - else expressions.push(expression.map(e => e.map(t => t.value ?? t))); + else expressions.push(expression.map(e => e.map(t => t?.value ?? t))); } // Push all expressions to results, objectifying if necessary diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index f611f95..317c11c 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -2,7 +2,8 @@ "parse": { "simple": [ {"source": "id pr", "target": [{"id": ["pr"]}]}, - {"source": "userName eq \"Test\"", "target": [{"userName": ["eq", "Test"]}]}, + {"source": "pr pr", "target": [{"pr": ["pr"]}]}, + {"source": "userName Eq \"Test\"", "target": [{"userName": ["eq", "Test"]}]}, {"source": "displayName co \"Bob\"", "target": [{"displayName": ["co", "Bob"]}]}, {"source": "name.formatted sw \"Bob\"", "target": [{"name": {"formatted": ["sw", "Bob"]}}]}, {"source": "quota gt 1.5", "target": [{"quota": ["gt", 1.5]}]}, @@ -11,14 +12,19 @@ {"source": "emails.primary eq true", "target": [{"emails": {"primary": ["eq", true]}}]} ], "logical": [ + {"source": "not eq pr", "target": [{"eq": ["not", "pr"]}]}, + {"source": "NOT id pr", "target": [{"id": ["not", "pr"]}]}, {"source": "id pr and userName eq \"Test\"", "target": [{"id": ["pr"], "userName": ["eq", "Test"]}]}, {"source": "userName eq \"Test\" or displayName co \"Bob\"", "target": [{"userName": ["eq", "Test"]}, {"displayName": ["co", "Bob"]}]}, + {"source": "userName eq \"Test\" or displayName co \"Bob\" and quota gt 5", "target": [{"userName": ["eq", "Test"]}, {"displayName": ["co", "Bob"], "quota": ["gt", 5]}]}, {"source": "email.value ew \"@example.com\" and not userName eq \"Test\"", "target": [{"email": {"value": ["ew", "@example.com"]}, "userName": ["not", "eq", "Test"]}]}, {"source": "email.type eq \"work\" or not userName ne \"Test\"", "target": [{"email": {"type": ["eq", "work"]}}, {"userName": ["not", "ne", "Test"]}]}, - {"source": "email.type eq \"work\" and not email.value ew \"@example.com\"", "target": [{"email": {"type": ["eq", "work"], "value": ["not", "ew", "@example.com"]}}]}, + {"source": "email.type eq \"work\" AND not email.value ew \"@example.com\"", "target": [{"email": {"type": ["eq", "work"], "value": ["not", "ew", "@example.com"]}}]}, + {"source": "NOT email.type eq \"work\" and not email.value ew \"@example.com\"", "target": [{"email": {"type": ["not", "eq", "work"], "value": ["not", "ew", "@example.com"]}}]}, {"source": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\"", "target": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}]}, {"source": "quota gt 1.5 and quota lt 2", "target": [{"quota": [["gt", 1.5], ["lt", 2]]}]}, - {"source": "userName sw \"A\" and userName ew \"Z\" and userName co \"m\"", "target": [{"userName": [["sw", "A"], ["ew", "Z"], ["co", "m"]]}]} + {"source": "userName sw \"A\" and userName ew \"Z\" and userName co \"m\"", "target": [{"userName": [["sw", "A"], ["ew", "Z"], ["co", "m"]]}]}, + {"source": "NOt not sw \"A\" and userName ew \"Z\" and userName co \"m\"", "target": [{"not": ["not", "sw", "A"], "userName": [["ew", "Z"], ["co", "m"]]}]} ], "grouping": [ { @@ -216,7 +222,7 @@ ], "negations": [ {"expression": {"userName": ["not", "pr"]}, "expected": []}, - {"expression": {"userName": ["not", "sw", "A"]}, "expected": [2, 3, 4]}, + {"expression": {"userName": ["nOt", "sw", "A"]}, "expected": [2, 3, 4]}, {"expression": {"exists": ["not", "pr"]}, "expected": [2, 4]}, {"expression": {"date": ["not", "co", "2021-09"]}, "expected": [1, 3]}, {"expression": {"date": ["not", "le", "2021-09-08T23:02:28.986Z"]}, "expected": [2]} From c34584eb495351ae2e04c1e6352e7a422a4e7690 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 23 May 2023 17:35:36 +1000 Subject: [PATCH 86/93] Fix(SCIMMY.Types.Filter): parsing of very complex grouped expressions --- src/lib/types/filter.js | 5 +++-- test/lib/types/filter.json | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 810aba4..85a4d12 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -239,12 +239,13 @@ export class Filter extends Array { /** * Divide a list of tokens into sets split by a given logical operator for parsing - * @param {Object[]} tokens - list of token objects in a query to divide by the given logical operation + * @param {Object[]} input - list of token objects in a query to divide by the given logical operation * @param {String} operator - the logical operator to divide the tokens by * @returns {Array} the supplied list of tokens split wherever the given operator occurred * @private */ - static #operations(tokens, operator) { + static #operations(input, operator) { + const tokens = [...input]; const operations = []; for (let token of [...tokens]) { diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 317c11c..8a4033c 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -35,6 +35,13 @@ "source": "emails[type eq \"work\"].value ew \"@example.org\"", "target": [{"emails": {"type": ["eq", "work"], "value": ["ew", "@example.org"]}}] }, + { + "source": "userName sw \"A\" and not (userName ew \"Z\" or displayName co \"Bob\")", + "target": [ + {"userName": [["sw", "A"], ["not", "ew", "Z"]]}, + {"userName": ["sw", "A"], "displayName": ["not", "co", "Bob"]} + ] + }, { "source": "emails[type eq \"work\" or type eq \"home\"].value ew \"@example.org\"", "target": [ @@ -42,6 +49,13 @@ {"emails": {"type": ["eq", "home"], "value": ["ew", "@example.org"]}} ] }, + { + "source": "userType eq \"Employee\" and (emails co \"example.com\" or emails.value co \"example.org\")", + "target": [ + {"userType": ["eq", "Employee"], "emails": ["co", "example.com"]}, + {"userType": ["eq", "Employee"], "emails": {"value": ["co", "example.org"]}} + ] + }, { "source": "userType ne \"Employee\" and not (emails co \"example.com\" or emails.value co \"example.org\")", "target": [ @@ -120,6 +134,15 @@ {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} ] }, + { + "source": "(userType eq \"Employee\" or userType eq \"Manager\") and emails[type eq \"work\" or (primary eq true and value co \"@example.com\")].display co \"Work\"", + "target": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Manager"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Manager"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, { "source": "userType eq \"Employee\" or emails[type eq \"work\" or (primary eq true and value co \"@example.com\")].display co \"Work\"", "target": [ From 489b8280319a1ec46908d8d5624ed09d8abcbe14 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 23 May 2023 17:36:27 +1000 Subject: [PATCH 87/93] Docs(SCIMMY.Types.Filter): extended usage details --- src/lib/types/filter.js | 172 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 171 insertions(+), 1 deletion(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 85a4d12..84ea3a2 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -35,7 +35,177 @@ const isoDate = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12 * SCIM Filter Type * @alias SCIMMY.Types.Filter * @summary - * * Parses SCIM [filter expressions](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) into object representations of the filter expression, for use in resource retrieval. + * * Parses SCIM [filter expressions](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) into object representations of the filter expression. + * @description + * This class provides a lexer implementation to tokenise and parse SCIM [filter expression](https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2.2) strings into meaningful object representations. + * It is used to automatically parse `attributes`, `excludedAttributes`, and `filter` expressions in the `{@link SCIMMY.Types.Resource}` class, and by extension, each Resource implementation. + * The SchemaDefinition `{@link SCIMMY.Types.SchemaDefinition#coerce|#coerce()}` method uses instances of this class, typically sourced + * from a Resource instance's `attributes` property, to determine which attributes to include or exclude on coerced resources. + * It is also used for resolving complex multi-valued attribute operations in SCIMMY's {@link SCIMMY.Messages.PatchOp|PatchOp} implementation. + * + * ### Object Representation + * When instantiated with a valid filter expression string, the expression is parsed into an array of objects representing the given expression. + * + * The properties of each object are directly sourced from attribute names parsed in the expression. + * As the class intentionally has no knowledge of the underlying attribute names associated with a schema, + * the properties of the object are case-sensitive, and will match the case of the attribute name provided in the filter. + * ```js + * // For the filter expressions... + * 'userName eq "Test"', and 'uSerName eq "Test"' + * // ...the object representations are + * [ {userName: ["eq", "Test"]} ], and [ {uSerName: ["eq", "Test"]} ] + * ``` + * + * #### Logical Operations + * ##### `and` + * For each logical `and` operation in the expression, a new property is added to the object. + * ```js + * // For the filter expression... + * 'userName co "a" and name.formatted sw "Bob" and name.honoraryPrefix eq "Mr"' + * // ...the object representation is + * [ {userName: ["co", "a"], name: {formatted: ["sw", "Bob"], honoraryPrefix: ["eq", "Mr"]}} ] + * ``` + * + * When an attribute name is specified multiple times in a logical `and` operation, the expressions are combined into a new array containing each individual expression. + * ```js + * // For the filter expression... + * 'userName sw "A" and userName ew "z"' + * // ...the object representation is + * [ {userName: [["sw", "A"], ["ew", "Z"]]} ] + * ``` + * + * ##### `or` + * For each logical `or` operation in the expression, a new object is added to the filter array. + * ```js + * // For the filter expression... + * 'userName eq "Test" or displayName co "Bob"' + * // ...the object representation is + * [ + * {userName: ["eq", "Test"]}, + * {displayName: ["co", "Bob"]} + * ] + * ``` + * + * When the logical `or` operation is combined with the logical `and` operation, the `and` operation takes precedence. + * ```js + * // For the filter expression... + * 'userName eq "Test" or displayName co "Bob" and quota gt 5' + * // ...the object representation is + * [ + * {userName: ["eq", "Test"]}, + * {displayName: ["co", "Bob"], quota: ["gt", 5]} + * ] + * ``` + * + * ##### `not` + * Logical `not` operations in an expression are added to an object property's array of conditions. + * ```js + * // For the filter expression... + * 'not userName eq "Test"' + * // ...the object representation is + * [ {userName: ["not", "eq", "Test"]} ] + * ``` + * + * For simplicity, the logical `not` operation is assumed to only apply to the directly following comparison statement in an expression. + * ```js + * // For the filter expression... + * 'userName sw "A" and not userName ew "Z" or displayName co "Bob"' + * // ...the object representation is + * [ + * {userName: [["sw", "A"], ["not", "ew", "Z"]]}, + * {displayName: ["co", "Bob"]} + * ] + * ``` + * + * If needed, logical `not` operations can be applied to multiple comparison statements using grouping operations. + * ```js + * // For the filter expression... + * 'userName sw "A" and not (userName ew "Z" or displayName co "Bob")' + * // ...the object representation is + * [ + * {userName: [["sw", "A"], ["not", "ew", "Z"]]}, + * {userName: ["sw", "A"], displayName: ["not", "co", "Bob"]} + * ] + * ``` + * + * #### Grouping Operations + * As per the order of operations in the SCIM protocol specification, grouping operations are evaluated ahead of any simpler expressions. + * + * In more complex scenarios, expressions can be grouped using `(` and `)` parentheses to change the standard order of operations. + * This is referred to as *precedence grouping*. + * ```js + * // For the filter expression... + * 'userType eq "Employee" and (emails co "example.com" or emails.value co "example.org")' + * // ...the object representation is + * [ + * {userType: ["eq", "Employee"], emails: ["co", "example.com"]}, + * {userType: ["eq", "Employee"], emails: {value: ["co", "example.org"]}} + * ] + * ``` + * + * Grouping operations can also be applied to complex attributes using the `[` and `]` brackets to create filters that target sub-attributes. + * This is referred to as *complex attribute filter grouping*. + * ```js + * // For the filter expression... + * 'emails[type eq "work" and value co "@example.com"] or ims[type eq "xmpp" and value co "@foo.com"]' + * // ...the object representation is + * [ + * {emails: {type: ["eq", "work"], value: ["co", "@example.com"]}}, + * {ims: {type: ["eq", "xmpp"], value: ["co", "@foo.com"]}} + * ] + * ``` + * + * Complex attribute filter grouping can also be used to target sub-attribute values of multi-valued attributes with specific values. + * ```js + * // For the filter expression... + * 'emails[type eq "work" or type eq "home"].values[domain ew "@example.org" or domain ew "@example.com"]' + * // ...the object representation is + * [ + * {emails: {type: ["eq", "work"], values: {domain: ["ew", "@example.org"]}}}, + * {emails: {type: ["eq", "work"], values: {domain: ["ew", "@example.com"]}}}, + * {emails: {type: ["eq", "home"], values: {domain: ["ew", "@example.org"]}}}, + * {emails: {type: ["eq", "home"], values: {domain: ["ew", "@example.com"]}}} + * ] + * ``` + * + * Precedence and complex attribute filter grouping can also be combined. + * ```js + * // For the filter expression... + * '(userType eq "Employee" or userType eq "Manager") and emails[type eq "work" or (primary eq true and value co "@example.com")].display co "Work"' + * // ...the object representation is + * [ + * {userType: ["eq", "Employee"], emails: {type: ["eq", "work"], display: ["co", "Work"]}}, + * {userType: ["eq", "Employee"], emails: {primary: ["eq", true], value: ["co", "@example.com"], display: ["co", "Work"]}}, + * {userType: ["eq", "Manager"], emails: {type: ["eq", "work"], display: ["co", "Work"]}}, + * {userType: ["eq", "Manager"], emails: {primary: ["eq", true], value: ["co", "@example.com"], display: ["co", "Work"]}} + * ] + * ``` + * + * ### Other Implementations + * It is not possible to replace internal use of the Filter class inside SCIMMY's {@link SCIMMY.Messages.PatchOp|PatchOp} and `{@link SCIMMY.Types.SchemaDefinition|SchemaDefinition}` implementations. + * Replacing use in the `attributes` property of an instance of `{@link SCIMMY.Types.Resource}`, while technically possible, is not recommended, + * as it may break attribute filtering in the `{@link SCIMMY.Types.SchemaDefinition#coerce|#coerce()}` method of SchemaDefinition instances. + * + * If SCIMMY's filter expression resource matching does not meet your needs, it can be substituted for another implementation + * (e.g. [scim2-parse-filter](https://github.com/thomaspoignant/scim2-parse-filter)) when filtering results within your implementation + * of each resource type's {@link SCIMMY.Types.Resource.ingress|ingress}/{@link SCIMMY.Types.Resource.egress|egress}/{@link SCIMMY.Types.Resource.degress|degress} handler methods. + * See `{@link SCIMMY.Types.Resource~gressHandler}` for more information on implementing handler methods. + * ```js + * // Import the necessary methods from the other implementation, and for accessing your data source + * import {parse, filter} from "scim2-parse-filter"; + * import {users} from "some-database-client"; + * + * // Register your ingress/egress/degress handler method + * SCIMMY.Resources.User.egress(async (resource) => { + * // Get the original expression string from the resource's filter property... + * const {expression} = resource.filter; + * // ...and parse/handle it with the other implementation + * const f = filter(parse(expression)); + * + * // Retrieve the data from your data source, and filter it as necessary + * return await users.find(/some query returning array/).filter(f); + * }); + * ``` */ export class Filter extends Array { // Make sure derivatives return native arrays From a3ba191dbe27703bd4763cb10bba058359362ef8 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 7 Jun 2023 17:10:22 +1000 Subject: [PATCH 88/93] Add(SCIMMY.Types.Filter): stringify, clone, and freeze expression objects --- src/lib/types/definition.js | 13 +-- src/lib/types/filter.js | 131 +++++++++++++++++------ test/lib/types/filter.js | 208 +++++++++++++++++++++++++----------- test/lib/types/filter.json | 77 +++++++++++++ 4 files changed, 326 insertions(+), 103 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index af17bf0..c48aaf9 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -319,29 +319,30 @@ export class SchemaDefinition { else { // Prepare resultant value storage const target = {}; + const filterable = {...filter}; const inclusions = attributes.map(({name}) => name); // Check for any negative filters - for (let key in {...filter}) { + for (let key in {...filterable}) { // Find the attribute by lower case name const {name, config: {returned} = {}} = attributes.find(a => a.name.toLowerCase() === key.toLowerCase()) ?? {}; // Mark the property as omitted from the result, and remove the spent filter - if (returned !== "always" && Array.isArray(filter[key]) && filter[key][0] === "np") { + if (returned !== "always" && Array.isArray(filterable[key]) && filterable[key][0] === "np") { inclusions.splice(inclusions.indexOf(name), 1); - delete filter[key]; + delete filterable[key]; } } // Check for remaining positive filters - if (Object.keys(filter).length) { + if (Object.keys(filterable).length) { // If there was a positive filter, ignore the negative filters inclusions.splice(0, inclusions.length); // Mark the positively filtered property as included in the result, and remove the spent filter - for (let key in {...filter}) if (Array.isArray(filter[key]) && filter[key][0] === "pr") { + for (let key in {...filterable}) if (Array.isArray(filterable[key]) && filterable[key][0] === "pr") { inclusions.push(key); - delete filter[key]; + delete filterable[key]; } } diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 84ea3a2..133b80b 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -213,10 +213,15 @@ export class Filter extends Array { return Array; } + /** + * The original string that was parsed by the filter, or the stringified representation of filter expression objects + * @member {String} + */ + expression; + /** * Instantiate and parse a new SCIM filter string or expression - * @param {String|Object[]} [expression] - the query string to parse, or an existing set of filter expressions - * @property {String} [expression] - the raw string that was parsed by the filter + * @param {String|Object|Object[]} [expression] - the query string to parse, or an existing filter expression object or set of objects */ constructor(expression = []) { // Make sure expression is a string, an object, or an array @@ -224,7 +229,7 @@ export class Filter extends Array { throw new TypeError("Expected 'expression' parameter to be a string, object, or array in Filter constructor"); // Prepare underlying array and reset inheritance - super(...(Object(expression) === expression ? Array.isArray(expression) ? expression : [expression] : [])); + super(); Object.setPrototypeOf(this, Filter.prototype); // Handle expression strings @@ -233,10 +238,16 @@ export class Filter extends Array { if (!expression.trim().length) throw new TypeError("Expected 'expression' parameter string value to not be empty in Filter constructor"); - // Save and parse the expression + // Parse and save the expression + this.push(...Filter.#parse(expression)); this.expression = expression; - this.splice(0, 0, ...Filter.#parse(String(expression))); + } else { + // Clone and trap expression objects, and get expression string + this.push(...Filter.#objectify(Array.isArray(expression) ? expression : [expression])); + this.expression = Filter.#stringify(this); } + + Object.freeze(this); } /** @@ -331,6 +342,47 @@ export class Filter extends Array { ); } + /** + * Turn a parsed filter expression object back into a string + * @param {SCIMMY.Types.Filter} filter - the SCIMMY filter instance to stringify + * @returns {String} the string representation of the given filter expression object + * @private + */ + static #stringify(filter) { + return filter.map((e) => Object.entries(e) + // Create a function that can traverse objects and add prefixes to attribute names + .map((function getMapper(prefix = "") { + return ([attr, expr]) => { + // If the expression is an array, turn it back into a string + if (Array.isArray(expr)) { + const expressions = []; + + // Handle logical "and" operations applied to a single attribute + for (let e of expr.every(e => Array.isArray(e)) ? expr : [expr]) { + // Copy expression so original isn't modified + const parts = [...e]; + // Then check for negations and extract the actual values + const negate = (parts[0].toLowerCase() === "not" ? parts.shift() : undefined); + const [comparator, expected] = parts; + const maybeValue = expected instanceof Date ? expected.toISOString() : expected; + const value = (typeof maybeValue === "string" ? `"${maybeValue}"` : (maybeValue !== undefined ? `${maybeValue}` : maybeValue)) + + // Add the stringified expression to the results + expressions.push([negate, `${prefix}${attr}`, comparator, value].filter(v => !!v).join(" ")); + } + + return expressions; + } + // Otherwise, go deeper to get the actual expression + else return Object.entries(expr).map(getMapper(`${prefix}${attr}.`)); + } + })()) + // Turn all joins into a single string... + .flat(Infinity).join(" and ") + // ...then turn all branches into a single string + ).join(" or "); + } + /** * Extract a list of tokens representing the supplied expression * @param {String} query - the expression to generate the token list for @@ -338,8 +390,8 @@ export class Filter extends Array { * @private */ static #tokenise(query = "") { - let tokens = [], - token; + const tokens = []; + let token; // Cycle through the query and tokenise it until it can't be tokenised anymore while (token = patterns.exec(query)) { @@ -432,42 +484,57 @@ export class Filter extends Array { /** * Translate a given set of expressions into their object representation - * @param {Array} expressions - list of expressions to combine into their object representation + * @param {Object|Object[]|Array} expressions - list of expressions to translate into their object representation * @returns {Object} translated representation of the given set of expressions * @private */ static #objectify(expressions = []) { - let result = {}; - + // If the supplied expression was an object, deeply clone it and trap everything along the way in proxies + if (Object.getPrototypeOf(expressions).constructor === Object) { + const catchAll = (target, prop) => {throw new TypeError(`Cannot modify property ${prop} of immutable Filter instance`)}; + const handleTraps = {set: catchAll, deleteProperty: catchAll, defineProperty: catchAll}; + + return new Proxy(Object.entries(expressions).reduce((res, [key, val]) => Object.assign(res, { + [key]: Array.isArray(val) ? new Proxy(val.map(v => Array.isArray(v) ? new Proxy([...v], handleTraps) : v), handleTraps) : Filter.#objectify(val) + }), {}), handleTraps); + } + // If every supplied expression was an object, make sure they've all been cloned and proxied + else if (expressions.every(e => Object.getPrototypeOf(e).constructor === Object)) { + return expressions.map(Filter.#objectify); + } // Go through every expression in the list, or handle a singular expression if that's what was given - for (let expression of (expressions.every(e => Array.isArray(e)) ? expressions : [expressions])) { - // Check if first token is negative for later evaluation - const negative = (expression.length === 4 ? expression.shift() : undefined)?.toLowerCase?.(); - // Extract expression parts and derive object path - const [path, comparator, expected] = expression; - const parts = path.split(pathSeparator).filter(p => p); - let value = expected, target = result; + else { + const result = {}; - // Construct the object - for (let key of parts) { - // Fix the attribute name - const name = `${key[0].toLowerCase()}${key.slice(1)}`; + for (let expression of (expressions.every(e => Array.isArray(e)) ? expressions : [expressions])) { + // Check if first token is negative for later evaluation + const negative = (expression.length === 4 ? expression.shift() : undefined)?.toLowerCase?.(); + // Extract expression parts and derive object path + const [path, comparator, expected] = expression; + const parts = path.split(pathSeparator).filter(p => p); + let value = expected, target = result; - // If there's more path to follow, keep digging - if (parts.indexOf(key) < parts.length - 1) target = (target[name] = target[name] ?? {}); - // Otherwise, we've reached our destination - else { - // Unwrap string and null values, and store the translated expression - value = (value === "null" ? null : (String(value).match(/^["].*["]$/) ? value.substring(1, value.length - 1) : value)); - const expression = [negative, comparator.toLowerCase(), value].filter(v => v !== undefined); + // Construct the object + for (let key of parts) { + // Fix the attribute name + const name = `${key[0].toLowerCase()}${key.slice(1)}`; - // Either store the single expression, or convert to array if attribute already has an expression defined - target[name] = (!Array.isArray(target[name]) ? expression : [...(target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]); + // If there's more path to follow, keep digging + if (parts.indexOf(key) < parts.length - 1) target = (target[name] = target[name] ?? {}); + // Otherwise, we've reached our destination + else { + // Unwrap string and null values, and store the translated expression + value = (value === "null" ? null : (String(value).match(/^["].*["]$/) ? value.substring(1, value.length - 1) : value)); + const expression = [negative, comparator.toLowerCase(), value].filter(v => v !== undefined); + + // Either store the single expression, or convert to array if attribute already has an expression defined + target[name] = (!Array.isArray(target[name]) ? expression : [...(target[name].every(Array.isArray) ? target[name] : [target[name]]), expression]); + } } } + + return Filter.#objectify(result); } - - return result; } /** diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index e957caa..d8df966 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -20,82 +20,159 @@ describe("SCIMMY.Types.Filter", () => { "Filter type class did not instantiate without arguments"); }); - it("should expect 'expression' argument to be a non-empty string or collection of objects", () => { + context("when 'expression' argument is defined", () => { const fixtures = [ - ["number value '1'", 1], - ["boolean value 'false'", false] + ["a primitive number", "number value '1'", 1], + ["a primitive boolean", "boolean value 'false'", false] ]; - assert.throws(() => new Filter(""), - {name: "TypeError", message: "Expected 'expression' parameter string value to not be empty in Filter constructor"}, - "Filter type class did not expect expression to be a non-empty string"); - - for (let [label, value] of fixtures) { - assert.throws(() => new Filter(value), - {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array in Filter constructor"}, - `Filter type class did not reject 'expression' parameter ${label}`); + for (let [label, descriptor, value] of fixtures) { + it(`should not be ${label}`, () => { + assert.throws(() => new Filter(value), + {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array in Filter constructor"}, + `Filter type class did not reject 'expression' parameter ${descriptor}`); + }); } }); - it("should expect expression to a be well formed SCIM filter string", () => { - assert.throws(() => new Filter("id -pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", message: "Unexpected token '-pr' in filter"}, - "Filter type class did not reject 'expression' parameter value 'id -pr' that was not well formed"); - }); - - it("should expect all grouping operators to be opened and closed in filter string expression", () => { - assert.throws(() => new Filter("[id pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Missing closing ']' token in filter '[id pr'"}, - "Filter type class did not reject 'expression' parameter with unmatched opening '[' bracket"); - assert.throws(() => new Filter("id pr]"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Unexpected token ']' in filter"}, - `Filter type class did not reject 'expression' parameter with unmatched closing ']' bracket`); - assert.throws(() => new Filter("(id pr"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Missing closing ')' token in filter '(id pr'"}, - "Filter type class did not reject 'expression' parameter with unmatched opening '(' bracket"); - assert.throws(() => new Filter("id pr)"), - {name: "SCIMError", status: 400, scimType: "invalidFilter", - message: "Unexpected token ')' in filter"}, - `Filter type class did not reject 'expression' parameter with unmatched closing ')' bracket`); - }); - - it("should parse simple expressions without logical or grouping operators", async () => { - const {parse: {simple: suite}} = await fixtures; + context("when 'expression' argument is a string", () => { + it("should not be empty", () => { + assert.throws(() => new Filter(""), + {name: "TypeError", message: "Expected 'expression' parameter string value to not be empty in Filter constructor"}, + "Filter type class did not expect expression to be a non-empty string"); + }); - for (let fixture of suite) { - assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, - `Filter type class failed to parse simple expression '${fixture.source}'`); - } - }); - - it("should parse expressions with logical operators", async () => { - const {parse: {logical: suite}} = await fixtures; + it("should be a well formed SCIM filter string expression", () => { + assert.throws(() => new Filter("id -pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", message: "Unexpected token '-pr' in filter"}, + "Filter type class did not reject 'expression' parameter value 'id -pr' that was not well formed"); + }); - for (let fixture of suite) { - assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, - `Filter type class failed to parse expression '${fixture.source}' with logical operators`); - } + it("should expect all grouping operators to be opened and closed", () => { + assert.throws(() => new Filter("[id pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Missing closing ']' token in filter '[id pr'"}, + "Filter type class did not reject 'expression' parameter with unmatched opening '[' bracket"); + assert.throws(() => new Filter("id pr]"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Unexpected token ']' in filter"}, + `Filter type class did not reject 'expression' parameter with unmatched closing ']' bracket`); + assert.throws(() => new Filter("(id pr"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Missing closing ')' token in filter '(id pr'"}, + "Filter type class did not reject 'expression' parameter with unmatched opening '(' bracket"); + assert.throws(() => new Filter("id pr)"), + {name: "SCIMError", status: 400, scimType: "invalidFilter", + message: "Unexpected token ')' in filter"}, + `Filter type class did not reject 'expression' parameter with unmatched closing ')' bracket`); + }); + + it("should parse simple expressions without logical or grouping operators", async function () { + const {parse: {simple: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse simple expression '${fixture.source}'`); + } + }); + + it("should parse expressions with logical operators", async function () { + const {parse: {logical: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse expression '${fixture.source}' with logical operators`); + } + }); + + it("should parse expressions with grouping operators", async function () { + const {parse: {grouping: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse expression '${fixture.source}' with grouping operators`); + } + }); + + it("should parse complex expressions with a mix of logical and grouping operators", async function () { + const {parse: {complex: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, + `Filter type class failed to parse complex expression '${fixture.source}'`); + } + }); }); - - it("should parse expressions with grouping operators", async () => { - const {parse: {grouping: suite}} = await fixtures; + }); + + describe("#expression", () => { + context("when 'expression' argument was a string at instantiation", () => { + it("should be defined", () => { + assert.ok("expression" in new Filter("id pr"), + "Instance member 'expression' was not defined"); + }); - for (let fixture of suite) { - assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, - `Filter type class failed to parse expression '${fixture.source}' with grouping operators`); - } + it("should be a string", () => { + assert.ok(typeof (new Filter("id pr")).expression === "string", + "Instance member 'expression' was not a string"); + }); + + it("should always equal supplied 'expression' string argument", async function () { + const {parse: {simple, logical, grouping, complex}} = await fixtures; + const suite = [...simple, ...logical, ...grouping, ...complex].map(f => f?.source).filter(f => f); + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.strictEqual((new Filter(fixture)).expression, fixture, + `Instance member 'expression' did not equal supplied expression string '${fixture}'`); + } + }); }); - it("should parse complex expressions with a mix of logical and grouping operators", async () => { - const {parse: {complex: suite}} = await fixtures; + context("when 'expression' argument was an object, or array of objects at instantiation", () => { + it("should be defined", () => { + assert.ok("expression" in new Filter({id: ["pr"]}), + "Instance member 'expression' was not defined"); + }); - for (let fixture of suite) { - assert.deepStrictEqual([...new Filter(fixture.source)], fixture.target, - `Filter type class failed to parse complex expression '${fixture.source}'`); - } + it("should be a string", () => { + assert.ok(typeof (new Filter({id: ["pr"]})).expression === "string", + "Instance member 'expression' was not a string"); + }); + + it("should stringify simple expression objects without logical or grouping operators", async function () { + const {expression: {simple: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target, + `Filter type class failed to stringify simple expression object '${JSON.stringify(fixture.source)}'`); + } + }); + + it("should stringify expression objects with logical operators", async function () { + const {expression: {logical: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target, + `Filter type class failed to stringify expression object '${JSON.stringify(fixture.source)}' with logical operators`); + } + }); + + it("should stringify complex expression objects with multiple branches and joins", async function () { + const {expression: {complex: suite}} = await fixtures; + + if (!suite.length) this.skip(); + else for (let fixture of suite) { + assert.deepStrictEqual((new Filter(fixture.source)).expression, fixture.target, + `Filter type class failed to stringify complex expression object '${JSON.stringify(fixture.source)}'`); + } + }); }); }); @@ -118,10 +195,11 @@ describe("SCIMMY.Types.Filter", () => { ]; for (let [key, label] of targets) { - it(`should ${label}`, async () => { + it(`should ${label}`, async function () { const {match: {source, targets: {[key]: suite}}} = await fixtures; - for (let fixture of suite) { + if (!suite.length) this.skip(); + else for (let fixture of suite) { assert.deepStrictEqual(new Filter(fixture.expression).match(source).map((v) => v.id), fixture.expected, `Unexpected matches in '${key}' fixture #${suite.indexOf(fixture) + 1} with expression '${JSON.stringify(fixture.expression)}'`); } diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index 8a4033c..eef7c89 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -160,6 +160,83 @@ } ] }, + "expression": { + "simple": [ + {"source": [{"id": ["pr"]}], "target": "id pr"}, + {"source": [{"pr": ["pr"]}], "target": "pr pr"}, + {"source": [{"userName": ["eq", "Test"]}], "target": "userName eq \"Test\""}, + {"source": [{"displayName": ["co", "Bob"]}], "target": "displayName co \"Bob\""}, + {"source": [{"name": {"formatted": ["sw", "Bob"]}}], "target": "name.formatted sw \"Bob\""}, + {"source": [{"quota": ["gt", 1.5]}], "target": "quota gt 1.5"}, + {"source": [{"userType": ["eq", null]}], "target": "userType eq null"}, + {"source": [{"active": ["eq", false]}], "target": "active eq false"}, + {"source": [{"emails": {"primary": ["eq", true]}}], "target": "emails.primary eq true"} + ], + "logical": [ + {"source": [{"eq": ["not", "pr"]}], "target": "not eq pr"}, + {"source": [{"id": ["not", "pr"]}], "target": "not id pr"}, + {"source": [{"id": ["pr"], "userName": ["eq", "Test"]}], "target": "id pr and userName eq \"Test\""}, + {"source": [{"userName": ["eq", "Test"]}, {"displayName": ["co", "Bob"]}], "target": "userName eq \"Test\" or displayName co \"Bob\""}, + {"source": [{"userName": ["eq", "Test"]}, {"displayName": ["co", "Bob"], "quota": ["gt", 5]}], "target": "userName eq \"Test\" or displayName co \"Bob\" and quota gt 5"}, + {"source": [{"email": {"value": ["ew", "@example.com"]}, "userName": ["not", "eq", "Test"]}], "target": "email.value ew \"@example.com\" and not userName eq \"Test\""}, + {"source": [{"email": {"type": ["eq", "work"]}}, {"userName": ["not", "ne", "Test"]}], "target": "email.type eq \"work\" or not userName ne \"Test\""}, + {"source": [{"email": {"type": ["eq", "work"], "value": ["not", "ew", "@example.com"]}}], "target": "email.type eq \"work\" and not email.value ew \"@example.com\""}, + {"source": [{"email": {"type": ["not", "eq", "work"], "value": ["not", "ew", "@example.com"]}}], "target": "not email.type eq \"work\" and not email.value ew \"@example.com\""}, + {"source": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}], "target": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\""}, + {"source": [{"quota": [["gt", 1.5], ["lt", 2]]}], "target": "quota gt 1.5 and quota lt 2"}, + {"source": [{"userName": [["sw", "A"], ["ew", "Z"], ["co", "m"]]}], "target": "userName sw \"A\" and userName ew \"Z\" and userName co \"m\""}, + {"source": [{"not": ["not", "sw", "A"], "userName": [["ew", "Z"], ["co", "m"]]}], "target": "not not sw \"A\" and userName ew \"Z\" and userName co \"m\""} + ], + "complex": [ + { + "target": "name.familyName eq \"Employee\" and emails.value co \"example.com\" or name.familyName eq \"Employee\" and emails.value co \"example.org\" or name.familyName eq \"Manager\" and emails.value co \"example.com\" or name.familyName eq \"Manager\" and emails.value co \"example.org\"", + "source": [ + {"name": {"familyName": ["eq", "Employee"]}, "emails": {"value": ["co", "example.com"]}}, + {"name": {"familyName": ["eq", "Employee"]}, "emails": {"value": ["co", "example.org"]}}, + {"name": {"familyName": ["eq", "Manager"]}, "emails": {"value": ["co", "example.com"]}}, + {"name": {"familyName": ["eq", "Manager"]}, "emails": {"value": ["co", "example.org"]}} + ] + }, + { + "target": "userType eq \"Employee\" and emails.type eq \"work\" or userType eq \"Employee\" and emails.primary eq true and emails.value co \"@example.com\"", + "source": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"]}} + ] + }, + { + "target": "userType eq \"Employee\" and emails.type eq \"work\" and emails.display co \"Work\" or userType eq \"Employee\" and emails.primary eq true and emails.value co \"@example.com\" and emails.display co \"Work\"", + "source": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, + { + "target": "userType eq \"Employee\" and emails.type eq \"work\" and emails.display co \"Work\" or userType eq \"Employee\" and emails.primary eq true and emails.value co \"@example.com\" and emails.display co \"Work\" or userType eq \"Manager\" and emails.type eq \"work\" and emails.display co \"Work\" or userType eq \"Manager\" and emails.primary eq true and emails.value co \"@example.com\" and emails.display co \"Work\"", + "source": [ + {"userType": ["eq", "Employee"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Employee"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Manager"], "emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"userType": ["eq", "Manager"], "emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, + { + "target": "userType eq \"Employee\" or emails.type eq \"work\" and emails.display co \"Work\" or emails.primary eq true and emails.value co \"@example.com\" and emails.display co \"Work\"", + "source": [ + {"userType": ["eq", "Employee"]}, + {"emails": {"type": ["eq", "work"], "display": ["co", "Work"]}}, + {"emails": {"primary": ["eq", true], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + }, + { + "target": "userType eq \"Employee\" or emails.type eq \"work\" and emails.primary eq false and emails.value co \"@example.com\" and emails.display co \"Work\"", + "source": [ + {"userType": ["eq", "Employee"]}, + {"emails": {"type": ["eq", "work"], "primary": ["eq", false], "value": ["co", "@example.com"], "display": ["co", "Work"]}} + ] + } + ] + }, "match": { "source": [ { From 0f7b7655195ec312f722f29e1aea6e6505811a7d Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Fri, 9 Jun 2023 13:47:07 +1000 Subject: [PATCH 89/93] Add(SCIMMY.Types.Filter): validate expression objects at instantiation --- src/lib/types/filter.js | 120 ++++++++++++++++++++++++++++++++------- test/lib/types/filter.js | 111 ++++++++++++++++++++++++++++++++++-- 2 files changed, 205 insertions(+), 26 deletions(-) diff --git a/src/lib/types/filter.js b/src/lib/types/filter.js index 133b80b..76afe4a 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -46,6 +46,11 @@ const isoDate = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12 * ### Object Representation * When instantiated with a valid filter expression string, the expression is parsed into an array of objects representing the given expression. * + * > **Note:** + * > It is also possible to substitute the expression string with an existing or well-formed expression object or set of objects. + * > As such, valid filters can be instantiated using any of the object representations below. + * > When instantiated this way, the `expression` property is dynamically generated from the supplied expression objects. + * * The properties of each object are directly sourced from attribute names parsed in the expression. * As the class intentionally has no knowledge of the underlying attribute names associated with a schema, * the properties of the object are case-sensitive, and will match the case of the attribute name provided in the filter. @@ -56,6 +61,15 @@ const isoDate = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12 * [ {userName: ["eq", "Test"]} ], and [ {uSerName: ["eq", "Test"]} ] * ``` * + * As SCIM attribute names MUST begin with a lower-case letter, they are the exception to this rule, + * and will automatically be cast to lower-case. + * ```js + * // For the filter expressions... + * 'UserName eq "Test"', and 'Name.FamilyName eq "Test"' + * // ...the object representations are + * [ {userName: ["eq", "Test"]} ], and [ {name: {familyName: ["eq", "Test"]}} ] + * ``` + * * #### Logical Operations * ##### `and` * For each logical `and` operation in the expression, a new property is added to the object. @@ -221,31 +235,29 @@ export class Filter extends Array { /** * Instantiate and parse a new SCIM filter string or expression - * @param {String|Object|Object[]} [expression] - the query string to parse, or an existing filter expression object or set of objects + * @param {String|Object|Object[]} expression - the query string to parse, or an existing filter expression object or set of objects */ - constructor(expression = []) { - // Make sure expression is a string, an object, or an array - if (!["string", "object"].includes(typeof expression)) - throw new TypeError("Expected 'expression' parameter to be a string, object, or array in Filter constructor"); + constructor(expression) { + // See if we're dealing with an expression string + const isString = typeof expression === "string"; + + // Make sure expression is a string, an object, or an array of objects + if (!isString && !(Array.isArray(expression) ? expression : [expression]).every(e => Object.getPrototypeOf(e).constructor === Object)) + throw new TypeError("Expected 'expression' parameter to be a string, object, or array of objects in Filter constructor"); + // Make sure the expression string isn't empty + if (isString && !expression.trim().length) + throw new TypeError("Expected 'expression' parameter string value to not be empty in Filter constructor"); // Prepare underlying array and reset inheritance - super(); - Object.setPrototypeOf(this, Filter.prototype); + Object.setPrototypeOf(super(), Filter.prototype); - // Handle expression strings - if (typeof expression === "string") { - // Make sure the expression string isn't empty - if (!expression.trim().length) - throw new TypeError("Expected 'expression' parameter string value to not be empty in Filter constructor"); - - // Parse and save the expression - this.push(...Filter.#parse(expression)); - this.expression = expression; - } else { - // Clone and trap expression objects, and get expression string - this.push(...Filter.#objectify(Array.isArray(expression) ? expression : [expression])); - this.expression = Filter.#stringify(this); - } + // Parse the expression if it was a string + if (isString) this.push(...Filter.#parse(expression)); + // Otherwise, clone and trap validated expression objects + else this.push(...Filter.#objectify(Filter.#validate(expression))); + + // Save the original expression string, or stringify expression objects + this.expression = (isString ? expression : Filter.#stringify(this)); Object.freeze(this); } @@ -342,6 +354,72 @@ export class Filter extends Array { ); } + /** + * Check an expression object or set of objects to make sure they are valid + * @param {Object|Object[]} expression - the expression object or set of objects to validate + * @param {Number} [originIndex] - the index of the original filter expression object for errors thrown while recursively validating + * @param {String} [prefix] - the path to prepend to attribute names in thrown errors + * @returns {Object[]} the original expression object or objects, wrapped in an array + * @private + */ + static #validate(expression, originIndex, prefix = "") { + // Wrap expression in array for validating + const expressions = Array.isArray(expression) ? expression : [expression]; + + // Go through each expression in the array and validate it + for (let e of expressions) { + // Preserve the top-level index of the expression for thrown errors + const index = originIndex ?? expressions.indexOf(e)+1; + const props = Object.entries(e); + + // Make sure the expression isn't empty... + if (!props.length) { + if (!prefix) throw new TypeError(`Missing expression properties for Filter expression object #${index}`); + else throw new TypeError(`Missing expressions for property '${prefix.slice(0, -1)}' of Filter expression object #${index}`); + } + + // Actually go through the expressions + for (let [attr, expr] of props) { + // Include prefix in attribute name of thrown errors + const name = `${prefix}${attr}`; + + // If expression is an array, validate it + if (Array.isArray(expr)) { + // See if we're dealing with nesting + const nested = expr.some(e => Array.isArray(e)); + + // Make sure expression is either singular or nested, not both + if (nested && expr.length && !expr.every(e => Array.isArray(e))) + throw new TypeError(`Unexpected nested array in property '${name}' of Filter expression object #${index}`); + + // Go through and make sure each expression is valid + for (let e of (nested ? expr : [expr])) { + // Extract comparator and expected value + const [comparator, expected] = e.slice(e[0]?.toLowerCase?.() === "not" ? 1 : 0); + + // Make sure there was a comparator + if (!comparator) + throw new TypeError(`Missing comparator in property '${name}' of Filter expression object #${index}`); + // Make sure presence comparators don't include expected values + if (["pr", "np"].includes(comparator.toLowerCase()) && expected !== undefined) + throw new TypeError(`Unexpected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`); + // Make sure expected value was defined for any other comparator + if (expected === undefined && !["pr", "np"].includes(comparator.toLowerCase())) + throw new TypeError(`Missing expected comparison value for '${comparator}' comparator in property '${name}' of Filter expression object #${index}`); + } + } + // If expression is an object, traverse it + else if (Object.getPrototypeOf(expr).constructor === Object) + Filter.#validate(expr, index, `${name}.`); + // Otherwise, the expression is not valid + else throw new TypeError(`Expected plain object ${name ? `or expression array in property '${name}' of` : "for"} Filter expression object #${index}`) + } + } + + // All looks good, return the expression array + return expressions; + } + /** * Turn a parsed filter expression object back into a string * @param {SCIMMY.Types.Filter} filter - the SCIMMY filter instance to stringify diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index d8df966..9c11f27 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -10,26 +10,27 @@ const fixtures = fs.readFile(path.join(basepath, "./filter.json"), "utf8").then( describe("SCIMMY.Types.Filter", () => { it("should extend native 'Array' class", () => { - assert.ok(new Filter() instanceof Array, + assert.ok(new Filter("id pr") instanceof Array, "Filter type class did not extend native 'Array' class"); }); describe("@constructor", () => { it("should not require arguments", () => { - assert.doesNotThrow(() => new Filter(), + assert.doesNotThrow(() => new Filter("id pr"), "Filter type class did not instantiate without arguments"); }); context("when 'expression' argument is defined", () => { const fixtures = [ ["a primitive number", "number value '1'", 1], - ["a primitive boolean", "boolean value 'false'", false] + ["a primitive boolean", "boolean value 'false'", false], + ["an instance of Date", "date instance value", new Date()] ]; for (let [label, descriptor, value] of fixtures) { it(`should not be ${label}`, () => { assert.throws(() => new Filter(value), - {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array in Filter constructor"}, + {name: "TypeError", message: "Expected 'expression' parameter to be a string, object, or array of objects in Filter constructor"}, `Filter type class did not reject 'expression' parameter ${descriptor}`); }); } @@ -107,6 +108,106 @@ describe("SCIMMY.Types.Filter", () => { } }); }); + + context("when 'expression' argument is an object", () => { + it("should not be an empty object", () => { + assert.throws(() => new Filter({}), + {name: "TypeError", message: "Missing expression properties for Filter expression object #1"}, + "Filter type class did not reject an empty object"); + }); + + it("should expect all properties to be arrays or plain objects", () => { + assert.throws(() => new Filter({id: "pr"}), + {name: "TypeError", message: `Expected plain object or expression array in property 'id' of Filter expression object #1`}, + "Filter type class did not expect all properties to be arrays or plain objects"); + assert.throws(() => new Filter({id: ["pr"], name: {formatted: "pr"}}), + {name: "TypeError", message: `Expected plain object or expression array in property 'name.formatted' of Filter expression object #1`}, + "Filter type class did not expect all properties to be arrays or plain objects"); + }); + + it("should expect all object properties to not be empty objects", () => { + assert.throws(() => new Filter({id: {}}), + {name: "TypeError", message: `Missing expressions for property 'id' of Filter expression object #1`}, + "Filter type class did not expect object properties to not be empty objects"); + assert.throws(() => new Filter({id: ["pr"], name: {}}), + {name: "TypeError", message: `Missing expressions for property 'name' of Filter expression object #1`}, + "Filter type class did not expect object properties to not be empty objects"); + }); + + it("should expect expression comparators to be defined", () => { + assert.throws(() => new Filter({id: []}), + {name: "TypeError", message: `Missing comparator in property 'id' of Filter expression object #1`}, + "Filter type class did not expect expression comparators to be defined"); + assert.throws(() => new Filter({id: ["pr"], name: {formatted: []}}), + {name: "TypeError", message: `Missing comparator in property 'name.formatted' of Filter expression object #1`}, + "Filter type class did not expect expression comparators to be defined"); + }); + + it("should throw when nested arrays are mixed with singular expressions", () => { + assert.throws(() => new Filter({id: ["pr", ["sw", "A"]]}), + {name: "TypeError", message: "Unexpected nested array in property 'id' of Filter expression object #1"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + assert.throws(() => new Filter({name: {formatted: ["pr", ["sw", "A"]]}}), + {name: "TypeError", message: "Unexpected nested array in property 'name.formatted' of Filter expression object #1"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + assert.throws(() => new Filter({id: ["pr"], name: {formatted: ["pr", ["sw", "A"]]}}), + {name: "TypeError", message: "Unexpected nested array in property 'name.formatted' of Filter expression object #1"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + }); + }); + + context("when 'expression' argument is an array of objects", () => { + it("should not contain any empty objects", () => { + assert.throws(() => new Filter([{id: ["pr"]}, {}]), + {name: "TypeError", message: "Missing expression properties for Filter expression object #2"}, + "Filter type class did not reject expression containing empty objects"); + assert.throws(() => new Filter([{id: ["pr"]}, {name: {formatted: ["pr"]}}, {}]), + {name: "TypeError", message: "Missing expression properties for Filter expression object #3"}, + "Filter type class did not reject expression containing empty objects"); + assert.throws(() => new Filter([{name: {formatted: ["pr"]}}, {}, {id: ["pr"]}]), + {name: "TypeError", message: "Missing expression properties for Filter expression object #2"}, + "Filter type class did not reject expression containing empty objects"); + }); + + it("should expect all properties to be arrays or plain objects", () => { + assert.throws(() => new Filter([{id: ["pr"]}, {id: "pr"}]), + {name: "TypeError", message: `Expected plain object or expression array in property 'id' of Filter expression object #2`}, + "Filter type class did not expect all properties to be arrays or plain objects"); + assert.throws(() => new Filter([{id: ["pr"]}, {name: {formatted: "pr"}}]), + {name: "TypeError", message: `Expected plain object or expression array in property 'name.formatted' of Filter expression object #2`}, + "Filter type class did not expect all properties to be arrays or plain objects"); + }); + + it("should expect all object properties to not be empty objects", () => { + assert.throws(() => new Filter([{id: ["pr"]}, {id: {}}]), + {name: "TypeError", message: `Missing expressions for property 'id' of Filter expression object #2`}, + "Filter type class did not expect object properties to not be empty objects"); + assert.throws(() => new Filter([{id: ["pr"]}, {name: {}}]), + {name: "TypeError", message: `Missing expressions for property 'name' of Filter expression object #2`}, + "Filter type class did not expect object properties to not be empty objects"); + }); + + it("should expect expression comparators to be defined", () => { + assert.throws(() => new Filter([{id: ["pr"]}, {id: []}]), + {name: "TypeError", message: `Missing comparator in property 'id' of Filter expression object #2`}, + "Filter type class did not expect expression comparators to be defined"); + assert.throws(() => new Filter([{id: ["pr"]}, {name: {formatted: []}}]), + {name: "TypeError", message: `Missing comparator in property 'name.formatted' of Filter expression object #2`}, + "Filter type class did not expect expression comparators to be defined"); + }); + + it("should throw when nested arrays are mixed with singular expressions", () => { + assert.throws(() => new Filter([{id: ["pr", ["sw", "A"]]}, {id: ["pr"]}]), + {name: "TypeError", message: "Unexpected nested array in property 'id' of Filter expression object #1"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + assert.throws(() => new Filter({id: ["pr"], name: {formatted: ["pr", ["sw", "A"]]}}), + {name: "TypeError", message: "Unexpected nested array in property 'name.formatted' of Filter expression object #1"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + assert.throws(() => new Filter([{id: ["pr"]}, {name: {formatted: ["pr", ["sw", "A"]]}}]), + {name: "TypeError", message: "Unexpected nested array in property 'name.formatted' of Filter expression object #2"}, + "Filter type class did not throw when nested arrays were mixed with singular expressions"); + }); + }); }); describe("#expression", () => { @@ -178,7 +279,7 @@ describe("SCIMMY.Types.Filter", () => { describe("#match()", () => { it("should be implemented", () => { - assert.ok(typeof (new Filter()).match === "function", + assert.ok(typeof (new Filter("id pr")).match === "function", "Instance method 'match' not implemented"); }); From 005275f6704146582c8f031ac95db89332b9d906 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 14 Nov 2023 18:43:39 +1100 Subject: [PATCH 90/93] Fix(SCIMMY.Types.SchemaDefinition): allow removal of extension schema definitions --- src/lib/types/definition.js | 30 +++++--- src/lib/types/schema.js | 6 +- test/lib/types/definition.js | 139 +++++++++++++++++++++++------------ 3 files changed, 116 insertions(+), 59 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index c48aaf9..9a27599 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -175,24 +175,34 @@ export class SchemaDefinition { } /** - * Remove an attribute or subAttribute from a schema or attribute definition - * @param {String|String[]|SCIMMY.Types.Attribute|SCIMMY.Types.Attribute[]} attributes - the child attributes to remove from the schema or attribute definition + * Remove an attribute, extension schema, or subAttribute from a schema or attribute definition + * @param {...String} target - the name, or names, of attributes to remove from the schema definition + * @param {...SCIMMY.Types.Attribute} target - the attribute instance, or instances, to remove from the schema definition + * @param {...SCIMMY.Types.SchemaDefinition} target - the extension schema, or schemas, to remove from the schema definition * @returns {SCIMMY.Types.SchemaDefinition} this schema definition instance for chaining */ - truncate(attributes = []) { - for (let attrib of (Array.isArray(attributes) ? attributes : [attributes])) { - if (this.attributes.includes(attrib)) { + truncate(...target) { + const targets = target.flat(); + + for (let t of targets) { + if (this.attributes.includes(t)) { // Remove a found attribute from the schema definition - const index = this.attributes.indexOf(attrib); + const index = this.attributes.indexOf(t); if (index >= 0) this.attributes.splice(index, 1); - } else if (typeof attrib === "string") { + } else if (typeof t === "string") { // Look for the target attribute to remove, which throws a TypeError if not found - const target = this.attribute(attrib); + const target = this.attribute(t); // Either try truncate again with the target attribute - if (!attrib.includes(".")) this.truncate(target); + if (!t.includes(".")) this.truncate(target); // Or find the containing attribute and truncate it from there - else this.attribute(attrib.split(".").slice(0, -1).join(".")).truncate(target); + else this.attribute(t.split(".").slice(0, -1).join(".")).truncate(target); + } else if (t instanceof SchemaDefinition) { + // Look for the target schema extension to remove, which throws a TypeError if not found + const target = this.attribute(t.id); + // Remove a found schema extension from the schema definition + const index = this.attributes.indexOf(target); + if (index >= 0) this.attributes.splice(index, 1); } } diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js index 28081a9..988a450 100644 --- a/src/lib/types/schema.js +++ b/src/lib/types/schema.js @@ -63,11 +63,11 @@ export class Schema { } /** - * Remove an attribute or subAttribute from the schema definition - * @param {String|SCIMMY.Types.Attribute|Array} attributes - the child attributes to remove from the schema definition + * Remove an attribute, schema extension, or subAttribute from the schema's definition + * @param {SCIMMY.Types.Schema|String|SCIMMY.Types.Attribute|Array} attributes - the child attributes to remove from the schema definition */ static truncate(attributes) { - this.definition.truncate(attributes); + this.definition.truncate(attributes?.prototype instanceof Schema ? attributes.definition : attributes); } /** diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 031a6ab..e838a0e 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -317,59 +317,106 @@ describe("SCIMMY.Types.SchemaDefinition", () => { const actual = JSON.parse(JSON.stringify(definition.truncate().describe())); assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' modified attributes without arguments"); + "Instance method 'truncate' modified definition without arguments"); }); - it("should do nothing when definition does not directly include Attribute instances in 'attributes' argument", () => { - const definition = new SchemaDefinition(...Object.values(params)); - const expected = JSON.parse(JSON.stringify(definition.describe())); - const attribute = new Attribute("string", "id"); - const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); - }); - - it("should remove Attribute instances directly included in the definition", () => { - const attribute = new Attribute("string", "test"); - const definition = new SchemaDefinition(...Object.values(params), "", [attribute]); - const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); - const actual = JSON.parse(JSON.stringify(definition.truncate(attribute).describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); - }); - - it("should remove named attributes directly included in the definition", () => { - const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("string", "test")]); - const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); - const actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not remove named attribute directly included in the definition"); + context("when 'targets' argument contains an Attribute instance", () => { + it("should do nothing when definition does not directly include the instances", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const attribute = new Attribute("string", "id"); + const actual = JSON.parse(JSON.stringify(definition.truncate([attribute]).describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); + }); + + it("should remove instances directly included in the definition", () => { + const attributes = [new Attribute("string", "test"), new Attribute("string", "other")]; + const definition = new SchemaDefinition(...Object.values(params), "", attributes); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate(attributes).describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); + }); + }); + + context("when 'targets' argument contains an array of Attribute instances", () => { + it("should do nothing when definition does not directly include the instances", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const expected = JSON.parse(JSON.stringify(definition.describe())); + const attribute = new Attribute("string", "id"); + const actual = JSON.parse(JSON.stringify(definition.truncate([attribute]).describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not do nothing when foreign Attribute instance supplied in 'attributes' parameter"); + }); + + it("should remove instances directly included in the definition", () => { + const attributes = [new Attribute("string", "test"), new Attribute("string", "other")]; + const definition = new SchemaDefinition(...Object.values(params), "", attributes); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate(attributes).describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove Attribute instances directly included in the definition's attributes"); + }); }); - it("should remove named sub-attributes included in the definition", () => { - const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("complex", "test", {}, [new Attribute("string", "test")])]); - const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: [new Attribute("complex", "test").toJSON()]})); - const actual = JSON.parse(JSON.stringify(definition.truncate("test.test").describe())); - - assert.deepStrictEqual(actual, expected, - "Instance method 'truncate' did not remove named sub-attribute included in the definition"); + context("when 'targets' argument contains a SchemaDefinition instance", () => { + it("should expect definition to directly include the instance", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const nested = new SchemaDefinition(params.name, `${params.id}2`); + + assert.throws(() => definition.truncate(nested), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare schema extension for namespaced target '${params.id}2'`}, + "Instance method 'truncate' did not expect schema definition instance to exist"); + }); + + it("should remove instances directly included in the definition", () => { + const nested = new SchemaDefinition(params.name, `${params.id}2`); + const definition = new SchemaDefinition(...Object.values(params)).extend(nested); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate(nested).describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove schema definition instances directly included in the definition's attributes"); + }); }); - it("should expect named attributes and sub-attributes to exist", () => { - const definition = new SchemaDefinition(...Object.values(params)); + context("when 'targets' argument contains a string", () => { + it("should remove named attributes directly included in the definition", () => { + const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("string", "test")]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: []})); + const actual = JSON.parse(JSON.stringify(definition.truncate("test").describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove named attribute directly included in the definition"); + }); + + it("should remove named sub-attributes included in the definition", () => { + const definition = new SchemaDefinition(...Object.values(params), "", [new Attribute("complex", "test", {}, [new Attribute("string", "test")])]); + const expected = JSON.parse(JSON.stringify({...definition.describe(), attributes: [new Attribute("complex", "test").toJSON()]})); + const actual = JSON.parse(JSON.stringify(definition.truncate("test.test").describe())); + + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' did not remove named sub-attribute included in the definition"); + }); - assert.throws(() => definition.truncate("test"), - {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, - "Instance method 'truncate' did not expect named attribute 'test' to exist"); - assert.throws(() => definition.truncate("id.test"), - {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, - "Instance method 'truncate' did not expect named sub-attribute 'id.test' to exist"); - assert.throws(() => definition.truncate("meta.test"), - {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, - "Instance method 'truncate' did not expect named sub-attribute 'meta.test' to exist"); + it("should expect named attributes and sub-attributes to exist", () => { + const definition = new SchemaDefinition(...Object.values(params)); + + assert.throws(() => definition.truncate("test"), + {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, + "Instance method 'truncate' did not expect named attribute 'test' to exist"); + assert.throws(() => definition.truncate("id.test"), + {name: "TypeError", message: `Attribute 'id' of schema '${params.id}' is not of type 'complex' and does not define any subAttributes`}, + "Instance method 'truncate' did not expect named sub-attribute 'id.test' to exist"); + assert.throws(() => definition.truncate("meta.test"), + {name: "TypeError", message: `Attribute 'meta' of schema '${params.id}' does not declare subAttribute 'test'`}, + "Instance method 'truncate' did not expect named sub-attribute 'meta.test' to exist"); + }); }); }); From 812f43714bd78ef4702ade17c68494bc7a1c5a20 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Tue, 14 Nov 2023 18:59:39 +1100 Subject: [PATCH 91/93] Docs(SCIMMY.Schemas): extension schema definition addition and removal --- src/lib/schemas.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/lib/schemas.js b/src/lib/schemas.js index e68fa41..9a06a1f 100644 --- a/src/lib/schemas.js +++ b/src/lib/schemas.js @@ -65,6 +65,12 @@ import {ServiceProviderConfig} from "./schemas/spconfig.js"; * * // Add custom "mail" attribute to the Group schema definition * SCIMMY.Schemas.Group.definition.extend([new SCIMMY.Types.Attribute("string", "mail", {required: true})]); + * + * // Extend the User schema definition with the EnterpriseUser schema definition, and make it required + * SCIMMY.Schemas.User.definition.extend(SCIMMY.Schemas.EnterpriseUser.definition, true); + * + * // Remove the EnterpriseUser extension schema definition from the User schema definition + * SCIMMY.Schemas.User.definition.truncate(SCIMMY.Schemas.EnterpriseUser.definition); * ``` */ export default class Schemas { From 41db817249ec27a5bf067cb2f68882400dc90fd1 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 15 Nov 2023 15:56:45 +1100 Subject: [PATCH 92/93] Fix(SCIMMY.Types.Definition): correctly cast arrays when handling namespaced attribute values during coercion --- src/lib/types/definition.js | 17 +++++++++++++---- test/hooks/schemas.js | 28 ++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/lib/types/definition.js b/src/lib/types/definition.js index 9a27599..bad6eb6 100644 --- a/src/lib/types/definition.js +++ b/src/lib/types/definition.js @@ -257,7 +257,7 @@ export class SchemaDefinition { const namespacedValues = Object.keys(source).filter(k => k.startsWith(`${name.toLowerCase()}:`)) // Get the actual attribute name and value .map(k => [k.replace(`${name.toLowerCase()}:`, ""), source[k]]) - .reduce((res = {}, [name, value]) => { + .reduce((res, [name, value]) => { // Get attribute path parts and actual value const parts = name.toLowerCase().split("."); const target = {[parts.pop()]: value}; @@ -272,7 +272,7 @@ export class SchemaDefinition { // Assign and return Object.assign(parent, target); return res; - }, undefined); + }, {}); // Mix the namespaced attribute values in with the extension value const mixedSource = [source[name.toLowerCase()] ?? {}, namespacedValues ?? {}].reduce(function merge(t, s) { // Cast all key names to lower case to eliminate case sensitivity.... @@ -281,8 +281,17 @@ export class SchemaDefinition { // Merge all properties from s into t, joining arrays and objects for (let skey of Object.keys(s)) { const tkey = skey.toLowerCase(); - if (Array.isArray(t[tkey]) && Array.isArray(s[skey])) t[tkey].push(...s[skey]); + + // If source is an array... + if (Array.isArray(s[skey])) { + // ...and target is an array, merge them... + if (Array.isArray(t[tkey])) t[tkey].push(...s[skey]); + // ...otherwise, make target an array + else t[tkey] = [...s[skey]]; + } + // If source is a primitive value, copy it else if (s[skey] !== Object(s[skey])) t[tkey] = s[skey]; + // Finally, if source is neither an array nor primitive, merge it else t[tkey] = merge(t[tkey] ?? {}, s[skey]); } @@ -297,7 +306,7 @@ export class SchemaDefinition { // Coerce the mixed value, using only namespaced attributes for this extension target[name] = attribute.coerce(mixedSource, direction, basepath, [Object.keys(filter ?? {}) .filter(k => k.startsWith(`${name}:`)) - .reduce((res, key) => (((res[key.replace(`${name}:`, "")] = filter[key]) || true) && res), {}) + .reduce((res, key) => Object.assign(res, {[key.replace(`${name}:`, "")]: filter[key]}), {}) ]); } catch (ex) { // Rethrow exception with added context diff --git a/test/hooks/schemas.js b/test/hooks/schemas.js index d44c04d..3d93fed 100644 --- a/test/hooks/schemas.js +++ b/test/hooks/schemas.js @@ -107,10 +107,34 @@ export default { } }); + // https://github.com/scimmyjs/scimmy/issues/12 + it("should coerce complex multi-value attributes in schema extensions", async () => { + const {constructor = {}} = await fixtures; + const subAttribute = new Attribute("string", "name"); + const attribute = new Attribute("complex", "agencies", {multiValued: true}, [subAttribute]); + const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", [attribute]); + const source = {...constructor, [extension.id]: {[attribute.name]: [{[subAttribute.name]: "value"}]}}; + + try { + // Add the extension to the target + TargetSchema.extend(extension); + + // Construct an instance to test against, and get actual value for comparison + const instance = new TargetSchema(source); + const actual = JSON.parse(JSON.stringify(instance[extension.id][attribute.name])); + + assert.deepStrictEqual(actual, source[extension.id][attribute.name], + "Schema instance did not coerce complex multi-value attributes from schema extension"); + } finally { + // Remove the extension so it doesn't interfere later + TargetSchema.truncate("urn:ietf:params:scim:schemas:Extension"); + } + }); + it("should expect errors in extension schema coercion to be rethrown as SCIMErrors", async () => { + const {constructor = {}} = await fixtures; const attributes = [new Attribute("string", "testValue")]; const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); - const {constructor = {}} = await fixtures; const source = {...constructor, [`${extension.id}:testValue`]: "a string"}; try { @@ -145,8 +169,8 @@ export default { ]; // Get the extension and the source data ready - const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); const {constructor = {}} = await fixtures; + const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); const source = { ...constructor, [`${extension.id}:testValue.stringValue`]: "a string", From 8f2fd81fd0d9da58b3d2d61a3f2ac74be5259e97 Mon Sep 17 00:00:00 2001 From: Sam Lee-Lindsay Date: Wed, 15 Nov 2023 17:39:37 +1100 Subject: [PATCH 93/93] Fix(SCIMMY.Types.Attribute): throw missing required value error for bidirectional attributes --- src/lib/types/attribute.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index 6c49094..32ad34c 100644 --- a/src/lib/types/attribute.js +++ b/src/lib/types/attribute.js @@ -416,7 +416,7 @@ export class Attribute { const {required, multiValued, canonicalValues} = this.config; // If the attribute is required, make sure it has a value - if ((source === undefined || source === null) && required && this.config.direction === direction) + if ((source === undefined || source === null) && required && (direction !== "both" || this.config.direction === direction)) throw new TypeError(`Required attribute '${this.name}' is missing`); // If the attribute is multi-valued, make sure its value is a collection if (source !== undefined && !isComplexMultiValue && multiValued && !Array.isArray(source))