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 18cbc98..8e5cda7 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", @@ -37,16 +37,35 @@ "type": "git", "url": "git+https://github.com/scimmyjs/scimmy.git" }, + "imports": { + "#@/*": { + "default": "./src/*" + } + }, + "c8": { + "all": true, + "check-coverage": true, + "include": [ + "src/**/*.js" + ], + "reporter": [ + "clover", + "lcov" + ] + }, "bugs": { "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", + "c8": "^7.13.0", + "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", + "sinon": "^15.0.3", + "typescript": "^5.0.2" } } diff --git a/packager.js b/packager.js index d97c60c..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")}` }; /** @@ -40,7 +41,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 +104,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) { @@ -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)); } @@ -186,10 +195,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 diff --git a/src/lib/config.js b/src/lib/config.js index 9ac829c..28f59dd 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. @@ -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); diff --git a/src/lib/messages.js b/src/lib/messages.js index 6166228..4701e1a 100644 --- a/src/lib/messages.js +++ b/src/lib/messages.js @@ -1,6 +1,9 @@ import {Error} from "./messages/error.js"; 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 @@ -13,4 +16,7 @@ export default class Messages { static Error = Error; static ListResponse = ListResponse; static PatchOp = PatchOp; + static BulkRequest = BulkRequest; + static BulkResponse = BulkResponse; + static SearchRequest = SearchRequest; } \ No newline at end of file diff --git a/src/lib/messages/bulkrequest.js b/src/lib/messages/bulkrequest.js new file mode 100644 index 0000000..2491f5a --- /dev/null +++ b/src/lib/messages/bulkrequest.js @@ -0,0 +1,268 @@ +import {Error as ErrorMessage} from "./error.js"; +import {BulkResponse} from "./bulkresponse.js"; +import Types from "../types.js"; +import Resources from "../resources.js"; + +/** + * List of valid HTTP methods in a SCIM bulk request operation + * @enum + * @inner + * @constant + * @type {String[]} + * @alias ValidBulkMethods + * @memberOf SCIMMY.Messages.BulkRequest + * @default + */ +const validMethods = ["POST", "PUT", "PATCH", "DELETE"]; + +/** + * SCIM Bulk Request Message + * @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 BulkRequest { + /** + * SCIM BulkRequest Message Schema ID + * @type {String} + * @private + */ + static #id = "urn:ietf:params:scim:api:messages:2.0:BulkRequest"; + + /** + * Whether the incoming BulkRequest has been applied + * @type {Boolean} + * @private + */ + #dispatched = false; + + /** + * 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, 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 ?? {}; + + // Make sure specified schema is valid + 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"); + 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 BulkRequest body + this.schemas = [BulkRequest.#id]; + this.Operations = [...operations]; + if (failOnErrors) this.failOnErrors = failOnErrors; + } + + /** + * Apply the operations specified by the supplied BulkRequest + * @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())) { + // 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 + 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 + 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 + const {method, bulkId: opBulkId, path = "", data} = op; + // Ignore the bulkId unless method is POST + 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 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 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; + } + + // 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) => 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 { + // 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}), {})); + + // Set the ID for future use and resolve pending references + Object.assign(data, {id}) + bulkIds.get(bulkId).resolve(id); + } + + 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`); + } + } + + // 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 + 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); + errorCount++; + + // Also reject the pending bulkId promise as no resource ID can exist + if (bulkId && bulkIds.has(bulkId)) bulkIds.get(bulkId).reject(error); + } + + return result; + })()); + + // 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/src/lib/messages/bulkresponse.js b/src/lib/messages/bulkresponse.js new file mode 100644 index 0000000..30a37ca --- /dev/null +++ b/src/lib/messages/bulkresponse.js @@ -0,0 +1,50 @@ +/** + * SCIM Bulk Response Message + * @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("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 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 1f32b4e..1ac6fd0 100644 --- a/src/lib/messages/patchop.js +++ b/src/lib/messages/patchop.js @@ -13,12 +13,12 @@ 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), - 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 @@ -201,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) { @@ -216,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)); @@ -241,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 }; } @@ -261,24 +249,10 @@ 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 { - // 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 - 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) { @@ -286,7 +260,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); @@ -301,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`); + } } } } @@ -317,7 +300,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) { @@ -326,20 +309,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)); @@ -347,13 +332,22 @@ 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 { // 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("."); @@ -361,4 +355,40 @@ export class PatchOp { this.#remove(index, parentPath, targets); } } + + /** + * Perform the "replace" operation on the resource + * @param {Number} index - the operation's location in the list of operations, for use in error messages + * @param {String} path - specifies path to the attribute being replaced + * @param {any|any[]} value - value being replaced from the resource or attribute specified by path + * @private + */ + #replace(index, path, value) { + try { + // Call remove, then call add! + try { + if (path !== undefined) this.#remove(index, path); + } 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 (ex) { + // If it's a multi-value target that doesn't exist, add to the collection instead + 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' + ex.message = ex.message.replace(/for '(add|remove)' op/, "for 'replace' op"); + throw ex; + } + } } \ 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..7a6af6e --- /dev/null +++ b/src/lib/messages/searchrequest.js @@ -0,0 +1,141 @@ +import {ListResponse} from "./listresponse.js"; +import Types from "../types.js"; +import Resources from "../resources.js"; + +/** + * SCIM Search Request Message + * @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) { + 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 + 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 = {}) { + 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)) + 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 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 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"); + + // Sanity checks have passed, assign values + 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; + + return this; + } + + /** + * Apply a search request operation, retrieving results from specified resource types + * @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())) { + // 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 + const 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) { + 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 + 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 + 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 diff --git a/src/lib/resources.js b/src/lib/resources.js index bf45ab1..0ad759f 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. @@ -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 5d322ea..d52ef9f 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} */ @@ -82,11 +75,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); @@ -139,9 +132,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; @@ -157,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 1fc68f1..840ee46 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} */ @@ -82,11 +75,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); @@ -139,9 +132,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; @@ -157,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 diff --git a/src/lib/schemas.js b/src/lib/schemas.js index f508aca..9a06a1f 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. @@ -65,12 +65,13 @@ 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. + * // 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 { // Store declared schema definitions for later retrieval diff --git a/src/lib/types/attribute.js b/src/lib/types/attribute.js index 9ad2f80..32ad34c 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 @@ -69,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) @@ -83,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")) @@ -103,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 @@ -134,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'`; @@ -161,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) @@ -175,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'`; @@ -225,26 +287,12 @@ 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. */ 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 @@ -257,60 +305,35 @@ 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}'`, - // 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 - [, 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, 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! @@ -328,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 @@ -347,16 +370,18 @@ 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 * @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 */ @@ -387,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 && (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)) @@ -493,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, { @@ -523,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/definition.js b/src/lib/types/definition.js index 2d3ccaf..bad6eb6 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. @@ -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) @@ -126,12 +126,12 @@ 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) { - 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 @@ -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 - let 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 - let 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); } } @@ -212,79 +222,92 @@ 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; }, {}); + // 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 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]); + } + + return t; + }, {}); // Attempt to coerce the schema extension if (!!required && !Object.keys(mixedSource).length) { 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) => Object.assign(res, {[key.replace(`${name}:`, "")]: filter[key]}), {}) + ]); } catch (ex) { // Rethrow exception with added context ex.message += ` in schema extension '${name}'`; @@ -294,7 +317,7 @@ export class SchemaDefinition { } } - return SchemaDefinition.#filter(target, {...filter}, this.attributes); + return SchemaDefinition.#filter(target, filter && {...filter}, this.attributes); } /** @@ -309,44 +332,59 @@ 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 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 - 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 - delete data[name]; - delete filter[key]; + // Mark the property as omitted from the result, and remove the spent filter + if (returned !== "always" && Array.isArray(filterable[key]) && filterable[key][0] === "np") { + inclusions.splice(inclusions.indexOf(name), 1); + delete filterable[key]; } } - // Check to see if there's any filters left - if (!Object.keys(filter).length) return data; - else { - // Prepare resultant value storage - let target = {} + // Check for remaining positive filters + if (Object.keys(filterable).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) { - // TODO: namespaced attributes and extensions + // Mark the positively filtered property as included in the result, and remove the spent filter + for (let key in {...filterable}) if (Array.isArray(filterable[key]) && filterable[key][0] === "pr") { + inclusions.push(key); + delete filterable[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 - let attribute = attributes.find(a => a.name === key) ?? {}, - {type, config: {returned, multiValued} = {}, subAttributes} = attribute; + 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 ~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 + // 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") { - let value = SchemaDefinition.#filter(data[key], filter[key], multiValued ? [] : subAttributes); + 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)) @@ -354,9 +392,9 @@ export class SchemaDefinition { } } } - - return target; } + + return target; } } } \ No newline at end of file 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/filter.js b/src/lib/types/filter.js index cb101cc..76afe4a 100644 --- a/src/lib/types/filter.js +++ b/src/lib/types/filter.js @@ -23,13 +23,203 @@ 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 = /(? **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. + * ```js + * // For the filter expressions... + * 'userName eq "Test"', and 'uSerName eq "Test"' + * // ...the object representations are + * [ {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. + * ```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 @@ -37,30 +227,39 @@ 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 - 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(expression) === expression ? Array.isArray(expression) ? expression : [expression] : [])); - 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"); - - // Save and parse the expression - this.expression = expression; - this.splice(0, 0, ...Filter.#parse(String(expression))); - } + // 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); } /** @@ -70,61 +269,252 @@ 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, 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))); - switch (comparator) { - case "co": - return String(value[attr]).includes(expected); + if (Array.isArray(actual)) { + // 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 result = null; - case "pr": - return attr in value; + // 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; + + // Check for negation and extract the comparator and expected values + 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.toLowerCase()) { + 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; + } + + result = (negate ? !result : result); + } - case "eq": - return value[attr] === expected; - - case "ne": - return value[attr] !== expected; + return result; } }))) ); } /** - * Parse a SCIM filter string into an array of objects representing the query filter - * @param {String} [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 + * 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 #parse(query = "") { - let results = [], - tokens = [], - token; + 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 + * @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 + * @returns {Object[]} a set of token objects representing the expression, with details on the token kinds + * @private + */ + static #tokenise(query = "") { + const tokens = []; + let token; // 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, string, grouping, 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) { - // Handle number and string values + // Handle number, string, boolean, and null values if (number !== undefined) tokens.push({type: "Number", value: Number(number)}); - if (string !== undefined) tokens.push({type: "Value", value: String(string.substring(1, string.length-1))}); + if (string !== undefined) tokens.push({type: "Value", value: `"${String(string.substring(1, string.length-1))}"`}); + if (boolean !== undefined) tokens.push({type: "Boolean", value: boolean === "true"}); + if (empty !== undefined) tokens.push({type: "Empty", value: "null"}); - // Handle grouped filters recursively - if (grouping !== undefined) tokens.push({ - type: "Group", value: Filter.#parse(grouping.substring(1, grouping.length - 1)) - }); + // Handle grouped filters + if (grouping !== undefined) tokens.push({type: "Group", value: grouping.substring(1, grouping.length - 1)}); + + // Handle attribute filters inline + if (attribute !== undefined) word = tokens.pop().value + attribute; // Handle operators, comparators, and attribute names - if (word !== undefined) tokens.push({ - type: (operators.includes(word) ? "Operator" : (comparators.includes(word) ? "Comparator" : "Word")), - value: word - }); + if (word !== undefined) { + // Compound words when last token was a word ending with "." + if (tokens.length && tokens[tokens.length-1].type === "Word" && tokens[tokens.length-1].value.endsWith(".")) + word = tokens.pop().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}); + } } // Move on to the next token in the query @@ -144,92 +534,225 @@ export class Filter extends Array { throw new SCIMError(400, "invalidFilter", reason); } - // Go through the tokens and collapse the wave function! - while (tokens.length > 0) { - // Get the next token - let {value: literal, type} = tokens.shift(), - result = {}, - operator; + return tokens; + } + + /** + * Divide a list of tokens into sets split by a given logical operator for parsing + * @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(input, operator) { + const tokens = [...input]; + const operations = []; + + for (let token of [...tokens]) { + // Found the target operator token, push preceding tokens as an operation + 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) + operations.push(tokens.splice(0)); + } + + return operations; + } + + /** + * Translate a given set of expressions 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 = []) { + // 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}; - // 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 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 + else { + const result = {}; - // Handle joining operators - if (type === "Operator") { - // Cache the current operator - operator = literal; + 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 operator is "and", get the last result to write the next statement to - if (operator === "and" && results.length > 0) result = results.pop(); - - // 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); + // Construct the object + for (let key of parts) { + // Fix the attribute name + const name = `${key[0].toLowerCase()}${key.slice(1)}`; - // Continue evaluating the stack but on the special negative ("!!") property - result = result["!!"] = Array.isArray(tokens[0]?.value) ? [] : {}; - } - - // 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 + 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]); + } } } - // 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(".").map(l => `${l[0].toLowerCase()}${l.slice(1)}`), - target; + return Filter.#objectify(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 = "") { + 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) { + 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 { + 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 + 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)); - // 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() : {}); + // 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 + 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; - // 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); + // 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]); + } + // Otherwise, delve into the path parts for complexities + else { + 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 + const [, 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 => { + 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?.toLowerCase?.(), `${spent.join(".")}.${path}`, comparator, value]; + })); + + 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]); + } + } + + // 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 ")}]); + } } - // 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] ?? {}); + // Evaluate the groups + for (let group of groups.splice(0)) { + // Check for negative and extract the group token + 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 + 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) + ]); + } + } + } + } } - // 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 ?? t))); + } + + // Push all expressions to results, objectifying if necessary + for (let expression of expressions) { + results.push(...(Array.isArray(query) ? (expression.every(t => Array.isArray(t)) ? expression : [expression]) : [Filter.#objectify(expression)])); } } diff --git a/src/lib/types/resource.js b/src/lib/types/resource.js index 91b5c57..f052f48 100644 --- a/src/lib/types/resource.js +++ b/src/lib/types/resource.js @@ -1,9 +1,10 @@ import {SCIMError} from "./error.js"; +import {SchemaDefinition} from "./definition.js"; 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. @@ -38,40 +39,21 @@ export class Resource { /** * Retrieves a resource's core schema - * @type {SCIMMY.Types.Schema} + * @type {typeof SCIMMY.Types.Schema} * @abstract */ static get schema() { 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 or not 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; } @@ -139,6 +121,13 @@ 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 @@ -147,14 +136,12 @@ 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, 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} : {}) }; } @@ -180,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)) @@ -197,7 +184,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"); @@ -226,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: sortBy} : {}), - ...(["ascending", "descending"].includes(sortOrder) ? {sortOrder: sortOrder} : {}), - ...(!Number.isNaN(startIndex) && Number.isInteger(startIndex) ? {startIndex: startIndex} : {}), - ...(!Number.isNaN(count) && Number.isInteger(count) ? {count: count} : {}) + ...(typeof sortBy === "string" ? {sortBy} : {}), + ...(["ascending", "descending"].includes(sortOrder) ? {sortOrder} : {}), + ...(!Number.isNaN(Number(startIndex)) && Number.isInteger(startIndex) ? {startIndex} : {}), + ...(!Number.isNaN(Number(count)) && Number.isInteger(count) ? {count} : {}) }; } } diff --git a/src/lib/types/schema.js b/src/lib/types/schema.js index 93dea92..988a450 100644 --- a/src/lib/types/schema.js +++ b/src/lib/types/schema.js @@ -3,7 +3,29 @@ import {Attribute} from "./attribute.js"; import {SCIMError} from "./error.js"; /** - * SCIM Schema + * 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 + * @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 * @summary * * Extendable class which provides the ability to construct resource instances with automated validation of conformity to a resource's schema definition. @@ -30,18 +52,22 @@ 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) { + 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); } /** - * 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); } /** @@ -50,13 +76,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 +98,11 @@ 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, { // Because why bother with case-sensitivity in a JSON-based standard? @@ -87,7 +118,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 +147,24 @@ 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 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 throw new SCIMError(400, "invalidValue", ex.message); @@ -146,19 +172,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 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..3d93fed --- /dev/null +++ b/test/hooks/schemas.js @@ -0,0 +1,229 @@ +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("Extension", "urn:ietf:params:scim:schemas:Extension"), 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: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:Extension"); + } + }); + + 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("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:Extension: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:Extension"); + } + }); + + // 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 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 {constructor = {}} = await fixtures; + const extension = new SchemaDefinition("Extension", "urn:ietf:params:scim:schemas:Extension", "", attributes); + 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"); + } + }); + + 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 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"); + }); + + 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 c9ab117..832196e 100644 --- a/test/lib/config.js +++ b/test/lib/config.js @@ -1,5 +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`); @@ -8,107 +14,124 @@ function returnsImmutableObject(name, config) { `Static method '${name}' returned a mutable object`); } -export let ConfigSuite = (SCIMMY) => { - 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", () => { - 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())); + describe(".get()", () => { + it("should be implemented", () => { + assert.ok(typeof SCIMMY.Config.get === "function", + "Static method 'get' was not implemented"); + }); + + it("should return an immutable object", () => ( + returnsImmutableObject("get", SCIMMY.Config.get()) + )); + }); + + describe(".set()", () => { + it("should be implemented", () => { + assert.ok(typeof SCIMMY.Config.set === "function", + "Static method 'set' was not implemented"); }); - describe(".set()", () => { - let origin = JSON.parse(JSON.stringify(SCIMMY.Config.get())); - after(() => SCIMMY.Config.set(origin)); + it("should return an immutable object", () => ( + returnsImmutableObject("set", SCIMMY.Config.set()) + )); + + it("should do nothing without arguments", () => { + const config = SCIMMY.Config.get(); - it("should have static method 'set'", () => { - assert.ok(typeof SCIMMY.Config.set === "function", - "Static method 'set' not defined"); + 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 return an immutable object", () => - returnsImmutableObject("set", SCIMMY.Config.set())); - - it("should do nothing without arguments", () => { - let config = SCIMMY.Config.get(); - - assert.deepStrictEqual(SCIMMY.Config.set(), config, - "Static method 'set' unexpectedly modified config"); + 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 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 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 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"); + } + + 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 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"); + 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", "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`); - }); - } - }); + } + + 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 b36545c..1b78069 100644 --- a/test/lib/messages.js +++ b/test/lib/messages.js @@ -1,15 +1,34 @@ import assert from "assert"; -import {ErrorSuite} from "./messages/error.js"; -import {ListResponseSuite} from "./messages/listresponse.js"; -import {PatchOpSuite} from "./messages/patchop.js"; +import Messages from "#@/lib/messages.js"; -export let MessagesSuite = (SCIMMY) => { - 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(SCIMMY); - ListResponseSuite(SCIMMY); - PatchOpSuite(SCIMMY); + 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 new file mode 100644 index 0000000..981ea7a --- /dev/null +++ b/test/lib/messages/bulkrequest.js @@ -0,0 +1,392 @@ +import {promises as fs} from "fs"; +import path from "path"; +import url from "url"; +import assert from "assert"; +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"; + +// 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: [{}, {}]}; + +/** + * BulkRequest Test Resource Class + * Because BulkRequest needs a set of implemented resources to test against + */ +class Test extends 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) { + 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)}), + 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 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"]}), + {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", {}] + ]; + + 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", {}] + ]; + + 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 be implemented", () => { + assert.ok(typeof (new BulkRequest({...template})).apply === "function", + "Instance method 'apply' was not implemented"); + }); + + 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 ErrorMessage(new SCIMError(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 () => { + const fixtures = [ + ["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 ErrorMessage(new SCIMError(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 () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "a string"}]})).apply())?.Operations; + const expected = [{status: "400", method: "a string", response: { + ...new ErrorMessage(new SCIMError(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 () => { + 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 ErrorMessage(new SCIMError(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 () => { + const fixtures = [ + ["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: "POST", path: value}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new ErrorMessage(new SCIMError(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 () => { + const actual = (await (new BulkRequest({...template, Operations: [{method: "POST", path: "/Test"}]})).apply())?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...new ErrorMessage(new SCIMError(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 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")) + }}]; + + 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 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, + "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 ErrorMessage(new SCIMError(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 () => { + const fixtures = [ + ["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: "POST", path: "/Test", bulkId: value}]})).apply([Test]))?.Operations; + const expected = [{status: "400", method: "POST", response: { + ...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, + `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 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, + "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] + ]; + + for (let op of suite) { + 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 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, + `Instance method 'apply' did not reject 'data' attribute ${label}`); + } + } + }); + + it("should stop processing operations when failOnErrors limit is reached", async () => { + const {inbound: {failOnErrors: suite}} = await fixtures; + + assert.ok((await (new 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()]); + + 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; + + for (let fixture of suite) { + const result = await (new 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}`); + } + }); + + 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()]); + + 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}`); + } + }); + + 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 diff --git a/test/lib/messages/bulkrequest.json b/test/lib/messages/bulkrequest.json new file mode 100644 index 0000000..d1e5e25 --- /dev/null +++ b/test/lib/messages/bulkrequest.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 diff --git a/test/lib/messages/bulkresponse.js b/test/lib/messages/bulkresponse.js new file mode 100644 index 0000000..491befe --- /dev/null +++ b/test/lib/messages/bulkresponse.js @@ -0,0 +1,42 @@ +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", () => { + 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("#resolve()", () => { + it("should be implemented", () => { + assert.ok(typeof (new BulkResponse()).resolve === "function", + "Instance method 'resolve' was not implemented"); + }); + + 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 diff --git a/test/lib/messages/error.js b/test/lib/messages/error.js index 876222c..f0454ff 100644 --- a/test/lib/messages/error.js +++ b/test/lib/messages/error.js @@ -2,60 +2,60 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import {Error as ErrorMessage} from "#@/lib/messages/error.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"}; - - it("should include static class 'Error'", () => - 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, +// 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", () => { + 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 () => { - let {inbound: suite} = await fixtures; + const {inbound: suite} = await fixtures; for (let fixture of suite) { - assert.throws(() => new SCIMMY.Messages.Error(fixture), + 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 SCIMMY.Messages.Error({status: "a string"}), + 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 SCIMMY.Messages.Error({status: 402}), + 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 SCIMMY.Messages.Error({scimType: "a string"}), + 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 () => { - let {outbound: {valid, invalid}} = await fixtures; + const {outbound: {valid, invalid}} = await fixtures; for (let fixture of valid) { - assert.deepStrictEqual({...(new SCIMMY.Messages.Error(fixture))}, {...template, ...fixture}, + assert.deepStrictEqual({...(new ErrorMessage(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), + 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 48ef653..07bd019 100644 --- a/test/lib/messages/listresponse.js +++ b/test/lib/messages/listresponse.js @@ -2,156 +2,183 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import {ListResponse} from "#@/lib/messages/listresponse.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}; - - 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 SCIMMY.Messages.ListResponse())}, template, +// 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", () => { + describe("@constructor", () => { + it("should not require arguments", () => { + assert.deepStrictEqual({...(new 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"]}), + 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 SCIMMY.Messages.ListResponse({schemas: [params.id, "nonsense"]}), + 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 SCIMMY.Messages.ListResponse([], {startIndex: value}), + 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 () => { - let {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}), + 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 () => { - let {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}), + 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 SCIMMY.Messages.ListResponse([], {sortBy: {}}), + 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 SCIMMY.Messages.ListResponse([], {sortOrder: "a string"}), + 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 SCIMMY.Messages.ListResponse([], {sortBy: "test", sortOrder: "a string"}), + 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", () => { - let list = new SCIMMY.Messages.ListResponse(); + it("should only sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source}} = await fixtures; + const list = new ListResponse(source, {sortOrder: "descending"}); - assert.ok("Resources" in list, - "Instance member 'Resources' not defined"); - assert.ok(Array.isArray(list.Resources), - "Instance member 'Resources' was not an array"); + 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 have instance member 'totalResults' that is a non-negative integer", () => { - let list = new SCIMMY.Messages.ListResponse(); + it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { + const {outbound: {source, targets: suite}} = await fixtures; - 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"); + 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}'`); + } + }); + }); + + describe("#Resources", () => { + it("should be defined", () => { + assert.ok("Resources" in new ListResponse(), + "Instance member 'Resources' was not defined"); }); - it("should use 'totalResults' value included in inbound requests", async () => { - let {inbound: suite} = await fixtures; + it("should be an array", () => { + assert.ok(Array.isArray(new ListResponse().Resources), + "Instance member 'Resources' was not an array"); + }); + + it("should not include more resources than 'itemsPerPage' parameter", async () => { + const {outbound: {source}} = await fixtures; - 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}`); + 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"); } }); + }); + + describe("#startIndex", () => { + it("should be defined", () => { + assert.ok("startIndex" in new ListResponse(), + "Instance member 'startIndex' was not defined"); + }); - for (let member of ["startIndex", "itemsPerPage"]) { - it(`should have instance member '${member}' that is a positive integer`, () => { - let 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`); - }); - } + it("should be a positive integer", () => { + const list = new ListResponse(); + + 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 only sort resources if 'sortBy' parameter is supplied", async () => { - let {outbound: {source}} = await fixtures, - list = new SCIMMY.Messages.ListResponse(source, {sortOrder: "descending"}); + it("should equal 'startIndex' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; - 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"); + 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}`); } }); + }); + + describe("#itemsPerPage", () => { + it("should be defined", () => { + assert.ok("itemsPerPage" in new ListResponse(), + "Instance member 'itemsPerPage' was not defined"); + }); - it("should correctly sort resources if 'sortBy' parameter is supplied", async () => { - let {outbound: {source, targets: suite}} = await fixtures; + 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"); + }); + + it("should equal 'itemsPerPage' value included in inbound requests", async () => { + const {inbound: suite} = await fixtures; for (let fixture of suite) { - let 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.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}`); } }); + }); + + describe("#totalResults", () => { + it("should be defined", () => { + assert.ok("totalResults" in new ListResponse(), + "Instance member 'totalResults' was not defined"); + }); - it("should not include more resources than 'itemsPerPage' parameter", async () => { - let {outbound: {source}} = await fixtures; + it("should be a positive integer", () => { + const list = new ListResponse(); - 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"); + 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 +}); \ No newline at end of file diff --git a/test/lib/messages/patchop.js b/test/lib/messages/patchop.js index c1ceb43..e386dd2 100644 --- a/test/lib/messages/patchop.js +++ b/test/lib/messages/patchop.js @@ -2,187 +2,311 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import {Attribute} from "#@/lib/types/attribute.js"; +import {PatchOp} from "#@/lib/messages/patchop.js"; +import {createSchemaClass} from "../../hooks/schemas.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]}; - - it("should include static class 'PatchOp'", () => - assert.ok(!!SCIMMY.Messages.PatchOp, "Static class 'PatchOp' not defined")); - - describe("SCIMMY.Messages.PatchOp", () => { +// 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"), + 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", () => { it("should not instantiate requests with invalid schemas", () => { - assert.throws(() => new SCIMMY.Messages.PatchOp({schemas: ["nonsense"]}), + 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 SCIMMY.Messages.PatchOp({schemas: [params.id, "nonsense"]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: "a string"}), + 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 SCIMMY.Messages.PatchOp({...template}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: []}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, "a string"]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, true]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, []]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{}]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: {}}, {value: "a string"}]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "a string"}]}), + 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 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")) { - let 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`); } } }); 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"}]}), + 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 SCIMMY.Messages.PatchOp({...template, Operations: [{op: "remove", path: "test"}, {op: "remove"}]}), + 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", () => { - let operations = [ + 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]}), + 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 be implemented", () => { + assert.ok(typeof (new PatchOp({...template, Operations: [{op: "add", value: {}}]})).apply === "function", + "Instance method 'apply' was not implemented"); + }); - 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 'resource' parameter to be an instance of SCIMMY.Types.Schema", async () => { - await assert.rejects(() => new SCIMMY.Messages.PatchOp({...template, Operations: [{op: "add", value: false}]}).apply(), + 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"}, + "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"}, + "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"); + } + }); + + 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}); - it("should support simple and complex 'add' operations", async () => { - let {inbound: {add: suite}} = await fixtures; + 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`}, + "Instance method 'apply' did not throw correct SCIMError at invalid operation with 'op' value 'test'"); + }); + + 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) { - 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}); + const message = new PatchOp({...template, Operations: fixture.ops}); + 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})); + + // 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(await message.apply(source), expected, - `PatchOp 'apply' did not support 'add' op specified in inbound fixture ${suite.indexOf(fixture)+1}`); + 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")), + if (["add", "replace"].includes(op)) { + it(`should expect 'value' to be an object when 'path' is not specified in '${op}' operations`, async () => { + 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`}, + `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 () => { + 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`}, + `Instance method 'apply' 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`}, + `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 () => { + 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: "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"); + message: `Failing as requested with value '${details.value?.throws}' for '${op}' op of operation 1 in PatchOp request body`}, + `Instance method 'apply' did not rethrow other exception as SCIMError with location details in '${op}' operations`); }); - it("should support simple and complex 'remove' operations", async () => { - let {inbound: {remove: suite}} = await fixtures; + 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}); - 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}`); - } + 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`}, + `Instance method 'apply' did not respect attribute mutability in '${op}' operations`); }); - it("should support simple and complex 'replace' operations", async () => { - let {inbound: {replace: suite}} = await fixtures; + 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}); - 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}`); - } + 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`}, + `Instance method 'apply' 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`}, + `Instance method 'apply' did not expect target attribute 'test' to exist in '${op}' operations`); + }); + } }); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/messages/patchop.json b/test/lib/messages/patchop.json index d0a9fff..dc754e0 100644 --- a/test/lib/messages/patchop.json +++ b/test/lib/messages/patchop.json @@ -8,6 +8,20 @@ {"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"} + ] + }, + { + "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": [ @@ -37,6 +51,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"}] } ] } diff --git a/test/lib/messages/searchrequest.js b/test/lib/messages/searchrequest.js new file mode 100644 index 0000000..0efaaf6 --- /dev/null +++ b/test/lib/messages/searchrequest.js @@ -0,0 +1,274 @@ +import assert from "assert"; +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"; +import {createResourceClass} from "../../hooks/resources.js"; + +// 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 +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", ""]] + ] +}; + +describe("SCIMMY.Messages.SearchRequest", () => { + const sandbox = sinon.createSandbox(); + + after(() => sandbox.restore()); + before(() => sandbox.stub(Resources.default, "declared").returns([User, Group])); + + describe("@constructor", () => { + it("should not require arguments", () => { + 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, 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}`); + } + }); + + it("should expect 'attributes' property of 'request' argument to be an array of non-empty strings, if specified", () => { + assert.doesNotThrow(() => new SearchRequest({...template, attributes: ["test"]}), + "SearchRequest did not instantiate with valid 'attributes' property non-empty string array value"); + + for (let [label, attributes] of suites.arrays) { + assert.throws(() => new SearchRequest({...template, attributes}), + {name: "SCIMError", status: 400, scimType: "invalidValue", + message: "Expected 'attributes' parameter to be an array of non-empty strings"}, + `SearchRequest instantiated with invalid 'attributes' 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + }); + + describe("#prepare()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SearchRequest()).prepare === "function", + "Instance method 'prepare' was not implemented"); + }); + + it("should return the same instance it was called from", () => { + const expected = new 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 SearchRequest().prepare({filter: "test"}), + "Instance method 'prepare' rejected valid 'filter' property string value 'test'"); + + 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + + 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, 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}`); + } + }); + }); + + describe("#apply()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SearchRequest()).apply === "function", + "Instance method 'apply' was not implemented"); + }); + + 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 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/resources.js b/test/lib/resources.js index e11dca2..0585c21 100644 --- a/test/lib/resources.js +++ b/test/lib/resources.js @@ -1,513 +1,164 @@ import assert from "assert"; -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 sinon from "sinon"; +import * as Schemas from "#@/lib/schemas.js"; +import Resources from "#@/lib/resources.js"; +import {createResourceClass} from "../hooks/resources.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]); - }; - - 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); - }; - - 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", () => { - let existing = TargetResource.basepath(), - 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", () => { - 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", () => { - 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}`); - } - }); - - 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"); - }); - - 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"); - }); - - 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"); - }); - - 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}`); - } - } - }); - - 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"); - }); - - 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"); - }); - - it("should expect 'message' argument to be an object", async () => { - let 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 () => { - 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"); - }); - - 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"); - }); - - 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"); - } - }) - }; +describe("SCIMMY.Resources", () => { + const sandbox = sinon.createSandbox(); - it("should include static class 'Resources'", () => - assert.ok(!!SCIMMY.Resources, "Static class 'Resources' not defined")); + 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(); + } - 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"); + 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' was not implemented"); + }); + + 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(Schemas.default.declare.calledWith(resource.schema.definition), + "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()", () => { + it("should be implemented", () => { + assert.ok(typeof Resources.declared === "function", + "Static method 'declared' was not implemented"); }); - 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"); - }); + it("should return all declared resources when called without arguments", () => { + assert.deepStrictEqual(Resources.declared(), {Test, User: Resources.User, Group: Resources.Group}, + "Static method 'declared' did not return all declared resources when called without arguments"); }); - SchemaSuite(SCIMMY, ResourcesHooks); - ResourceTypeSuite(SCIMMY, ResourcesHooks); - ServiceProviderConfigSuite(SCIMMY, ResourcesHooks); - UserSuite(SCIMMY, ResourcesHooks); - GroupSuite(SCIMMY, ResourcesHooks); + 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 'resource' string value 'User'"); + }); + + 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), + "Static method 'declared' did not find declaration status of undeclared 'ResourceType' resource by instance"); + }); }); -} \ 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..662a919 100644 --- a/test/lib/resources/group.js +++ b/test/lib/resources/group.js @@ -1,29 +1,24 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import ResourcesHooks from "../../hooks/resources.js"; +import {Group} from "#@/lib/resources/group.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)); - - 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 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)); - 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 +// 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)); + +describe("SCIMMY.Resources.Group", () => { + 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 38c734e..e1097f7 100644 --- a/test/lib/resources/resourcetype.js +++ b/test/lib/resources/resourcetype.js @@ -1,29 +1,37 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +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 "../../hooks/resources.js"; +import {ResourceType} from "#@/lib/resources/resourcetype.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)); - - it("should include static class 'ResourceType'", () => - assert.ok(!!SCIMMY.Resources.ResourceType, "Static class 'ResourceType' not defined")); +// 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)); + +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 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)); - 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)); - - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.ResourceType)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.ResourceType, false)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.ResourceType, fixtures)); + after(() => sandbox.restore()); + before(() => { + sandbox.stub(Resources.default, "declared") + .returns([User, Group]) + .withArgs(User.schema.definition.name).returns(User); }); -} \ No newline at end of file + + 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 436d313..700e963 100644 --- a/test/lib/resources/schema.js +++ b/test/lib/resources/schema.js @@ -1,29 +1,37 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +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 "../../hooks/resources.js"; +import {Schema} from "#@/lib/resources/schema.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)); - - it("should include static class 'Schema'", () => - assert.ok(!!SCIMMY.Resources.Schema, "Static class 'Schema' not defined")); +// 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.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 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)); - 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)); - - describe(".basepath()", ResourcesHooks.basepath(SCIMMY.Resources.Schema)); - describe("#constructor", ResourcesHooks.construct(SCIMMY.Resources.Schema, false)); - describe("#read()", ResourcesHooks.read(SCIMMY.Resources.Schema, fixtures)); + after(() => sandbox.restore()); + before(() => { + sandbox.stub(Schemas.default, "declared") + .returns([User.definition, Group.definition]) + .withArgs(User.definition.id).returns(User.definition); }); -} \ No newline at end of file + + 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 b00366f..7086bde 100644 --- a/test/lib/resources/spconfig.js +++ b/test/lib/resources/spconfig.js @@ -1,29 +1,35 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import sinon from "sinon"; +import * as Config from "#@/lib/config.js"; +import ResourcesHooks from "../../hooks/resources.js"; +import {ServiceProviderConfig} from "#@/lib/resources/spconfig.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)); +// 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)); + +describe("SCIMMY.Resources.ServiceProviderConfig", () => { + const sandbox = sinon.createSandbox(); - it("should include static class 'ServiceProviderConfig'", () => - assert.ok(!!SCIMMY.Resources.ServiceProviderConfig, "Static class 'ServiceProviderConfig' not defined")); + 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} + })); - 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)); - 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 + 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 a8647ec..9771f02 100644 --- a/test/lib/resources/user.js +++ b/test/lib/resources/user.js @@ -1,29 +1,24 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import ResourcesHooks from "../../hooks/resources.js"; +import {User} from "#@/lib/resources/user.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)); - - 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 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)); - 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 +// 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)); + +describe("SCIMMY.Resources.User", () => { + 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 diff --git a/test/lib/schemas.js b/test/lib/schemas.js index c30551e..67b029a 100644 --- a/test/lib/schemas.js +++ b/test/lib/schemas.js @@ -1,217 +1,118 @@ import assert from "assert"; -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 {SchemaDefinition} from "#@/lib/types/definition.js"; +import Schemas from "#@/lib/schemas.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); - - 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(); - - 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); - - 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 () => { - let {definition} = await fixtures; - - assert.deepStrictEqual(JSON.parse(JSON.stringify(TargetSchema.definition.describe("/Schemas"))), definition, - "Definition did not match sample schema defined in RFC7643"); - }); - }) - }; +describe("SCIMMY.Schemas", () => { + it("should include static class 'ResourceType'", () => { + assert.ok(!!Schemas.ResourceType, + "Static class 'ResourceType' not defined"); + }); - it("should include static class 'Schemas'", () => - assert.ok(!!SCIMMY.Schemas, "Static class 'Schemas' not defined")); + it("should include static class 'ServiceProviderConfig'", () => { + assert.ok(!!Schemas.ServiceProviderConfig, + "Static class 'ServiceProviderConfig' 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"); - }); + 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"); }); - 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 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", () => { - let 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); - } - }); + 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"); }); - ResourceTypeSuite(SCIMMY, SchemasHooks); - ServiceProviderConfigSuite(SCIMMY, SchemasHooks); - UserSuite(SCIMMY, SchemasHooks); - GroupSuite(SCIMMY, SchemasHooks); - EnterpriseUserSuite(SCIMMY, SchemasHooks); + 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 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); + } + }); }); -} \ 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..3102bd8 100644 --- a/test/lib/schemas/enterpriseuser.js +++ b/test/lib/schemas/enterpriseuser.js @@ -1,17 +1,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import SchemasHooks from "../../hooks/schemas.js"; +import {EnterpriseUser} from "#@/lib/schemas/enterpriseuser.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)); - - 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 +// 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)); + +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 ad0ffbd..88b6af7 100644 --- a/test/lib/schemas/group.js +++ b/test/lib/schemas/group.js @@ -1,17 +1,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import SchemasHooks from "../../hooks/schemas.js"; +import {Group} from "#@/lib/schemas/group.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)); - - 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 +// 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)); + +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 a01b7e9..3955546 100644 --- a/test/lib/schemas/resourcetype.js +++ b/test/lib/schemas/resourcetype.js @@ -1,17 +1,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import SchemasHooks from "../../hooks/schemas.js"; +import {ResourceType} from "#@/lib/schemas/resourcetype.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)); - - 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 +// 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)); + +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 7cc154a..83b7941 100644 --- a/test/lib/schemas/spconfig.js +++ b/test/lib/schemas/spconfig.js @@ -1,17 +1,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import SchemasHooks from "../../hooks/schemas.js"; +import {ServiceProviderConfig} from "#@/lib/schemas/spconfig.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)); - - 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 +// 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)); + +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 0641b52..a3ad605 100644 --- a/test/lib/schemas/user.js +++ b/test/lib/schemas/user.js @@ -1,17 +1,14 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; -import assert from "assert"; +import SchemasHooks from "../../hooks/schemas.js"; +import {User} from "#@/lib/schemas/user.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)); - - 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 +// 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)); + +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 cd8b1c4..a4ea044 100644 --- a/test/lib/types.js +++ b/test/lib/types.js @@ -1,21 +1,34 @@ import assert from "assert"; -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"; +import SCIMMY from "#@/scimmy.js"; -export let TypesSuite = (SCIMMY) => { - 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(SCIMMY); - SchemaDefinitionSuite(SCIMMY); - FilterSuite(SCIMMY); - ErrorSuite(SCIMMY); - SchemaSuite(SCIMMY); - ResourceSuite(SCIMMY); + 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 de89ef0..53d3231 100644 --- a/test/lib/types/attribute.js +++ b/test/lib/types/attribute.js @@ -2,39 +2,72 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import {Attribute} from "#@/lib/types/attribute.js"; -export function instantiateFromFixture(SCIMMY, fixture) { - let {type, name, mutability: m, uniqueness: u, subAttributes = [], ...config} = fixture; - - return new SCIMMY.Types.Attribute( +// 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)); + +/** + * 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((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 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 + * @param {Attribute[]} [fixture.subAttributes] - list of subAttributes to add to the attribute definition + */ +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); - it("should include static class 'Attribute'", () => - assert.ok(!!SCIMMY.Types.Attribute, "Static class 'Attribute' not defined")); + for (let [label, value] of valid) { + try { + if (multiValued) target.push(value) + else attribute.coerce(value); + } catch { + assert.fail(`Instance method 'coerce' rejected ${label} when attribute type was ${type}`) + } + } - describe("SCIMMY.Types.Attribute", () => { - it("should require valid 'type' argument at instantiation", () => { - assert.throws(() => new SCIMMY.Types.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}`); + } +} + +describe("SCIMMY.Types.Attribute", () => { + 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 SCIMMY.Types.Attribute("other", "other"), + 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 SCIMMY.Types.Attribute("string"), + 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"); - - let invalidNames = [ + + const invalidNames = [ [".", "invalid.name"], ["@", "invalid@name"], ["=", "invalid=name"], @@ -42,17 +75,17 @@ export let AttributeSuite = (SCIMMY) => { ]; for (let [char, name] of invalidNames) { - assert.throws(() => new SCIMMY.Types.Attribute("string", name), + 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 SCIMMY.Types.Attribute("string", "validName"), + 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 SCIMMY.Types.Attribute("string", "test", {}, [new SCIMMY.Types.Attribute("string", "other")]), + 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'"); }); @@ -60,7 +93,7 @@ export let AttributeSuite = (SCIMMY) => { 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}), + 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}'`); } @@ -69,23 +102,23 @@ export let AttributeSuite = (SCIMMY) => { 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"}), + 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 SCIMMY.Types.Attribute("string", "test", {[attrib]: 1}), + 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 SCIMMY.Types.Attribute("string", "test", {[attrib]: {}}), + 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 SCIMMY.Types.Attribute("string", "test", {[attrib]: new Date()}), + 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", () => { - let attribute = new SCIMMY.Types.Attribute("string", "test"); + const attribute = new Attribute("string", "test"); assert.throws(() => attribute.test = true, {name: "TypeError", message: "Cannot add property test, object is not extensible"}, @@ -97,160 +130,163 @@ export let AttributeSuite = (SCIMMY) => { {name: "TypeError", message: "Cannot delete property 'config' of #"}, "Attribute was not sealed after instantiation"); }); + }); + + describe("#toJSON()", () => { + it("should be implemented", () => { + assert.ok(typeof (new Attribute("string", "test")).toJSON === "function", + "Instance method 'toJSON' was 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 () => { - let {toJSON: suite} = await fixtures; + for (let fixture of suite) { + const attribute = instantiateFromFixture(fixture); - for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, 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 be implemented", () => { + assert.ok(typeof (new Attribute("string", "test")).truncate === "function", + "Instance method 'truncate' was 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; - it("should do nothing without arguments", async () => { - let {truncate: suite} = await fixtures; + for (let fixture of suite) { + const attribute = instantiateFromFixture(fixture); - for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, fixture); - - assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate())), fixture, - `Attribute 'truncate' fixture #${suite.indexOf(fixture)+1} modified attribute without arguments`); - } - }); + assert.deepStrictEqual(JSON.parse(JSON.stringify(attribute.truncate())), fixture, + `Attribute 'truncate' fixture #${suite.indexOf(fixture)+1} modified attribute without arguments`); + } + }); + + 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 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())); - - assert.deepStrictEqual(after, before, - "Instance method 'truncate' modified non-complex attribute"); - }); + 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 remove specified sub-attribute from 'subAttributes' collection", async () => { - let {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; - for (let fixture of suite) { - let attribute = instantiateFromFixture(SCIMMY, fixture), - comparison = {...fixture, subAttributes: [...fixture.subAttributes ?? []]}, - 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(target))), comparison, + `Attribute 'truncate' fixture #${suite.indexOf(fixture) + 1} did not remove specified sub-attribute '${target}'`); + } + }); + }); + + describe("#coerce()", () => { + it("should be implemented", () => { + assert.ok(typeof (new Attribute("string", "test")).coerce === "function", + "Instance method 'coerce' was not defined"); }); - 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 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 = {}; - // 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}`); - } + 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(), + {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 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`); - } - }); + try { + attribute.coerce(); + } catch { + assert.fail("Instance method 'coerce' rejected empty value when attribute was not required"); + } - it("should expect value to be an array when attribute is multi-valued", () => { - let 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"); - }); + 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 value to be singular when attribute is not multi-valued", () => { - let 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"); - }); + 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 canonical when attribute specifies canonicalValues characteristic", () => { - let 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'"); - }); + try { + attribute.coerce("Test"); + } catch { + assert.fail("Instance method 'coerce' rejected canonical value 'Test'"); + } - 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([]); - - 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"); - }); + 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'"); + }); + + 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'"); + } - it("should expect value to be a string when attribute type is 'string'", () => typedCoercion("string", { + 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.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], @@ -258,9 +294,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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: [ @@ -269,9 +307,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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"], @@ -279,9 +319,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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: [ @@ -290,9 +332,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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"], @@ -301,9 +345,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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: [ @@ -313,9 +359,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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"], @@ -324,9 +372,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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: [ @@ -336,9 +386,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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"], @@ -346,9 +398,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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: [ @@ -357,44 +411,89 @@ export let AttributeSuite = (SCIMMY) => { ["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", "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], + ["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", "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], + ["complex value", "complex", {}], + ["Date instance value", "dateTime", new Date()] + ] }); - - it("should expect value to be an object when attribute type is 'complex'", () => typedCoercion("complex", { + }); + + 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}'`, valid: [["complex value", {}]], invalid: [ @@ -403,9 +502,11 @@ export let AttributeSuite = (SCIMMY) => { ["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", { + }) + )); + + 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", {}]], @@ -415,7 +516,34 @@ export let AttributeSuite = (SCIMMY) => { ["boolean value 'false'", "boolean", false], ["Date instance value", "dateTime", new Date()] ] - })); + }) + )); + + 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 +}); \ No newline at end of file diff --git a/test/lib/types/definition.js b/test/lib/types/definition.js index 138f473..e838a0e 100644 --- a/test/lib/types/definition.js +++ b/test/lib/types/definition.js @@ -2,311 +2,410 @@ 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"; +import {Filter} from "#@/lib/types/filter.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"}; - - 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 SCIMMY.Types.SchemaDefinition()), +// 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", () => { + 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 SCIMMY.Types.SchemaDefinition("")), + 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 SCIMMY.Types.SchemaDefinition(false)), + 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 SCIMMY.Types.SchemaDefinition({})), + 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 at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition("Test")), + 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 SCIMMY.Types.SchemaDefinition("Test", "")), + 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 SCIMMY.Types.SchemaDefinition("Test", false)), + 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 SCIMMY.Types.SchemaDefinition("Test", {})), + 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 SCIMMY.Types.SchemaDefinition("Test", "test")), + 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 at instantiation", () => { - assert.throws(() => (new SCIMMY.Types.SchemaDefinition(...Object.values(params), false)), + 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 SCIMMY.Types.SchemaDefinition(...Object.values(params), {})), + 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"); }); + }); + + describe("#name", () => { + it("should be defined", () => { + assert.ok("name" in new SchemaDefinition(...Object.values(params)), + "Instance member 'name' was 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 be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.name === "string", + "Instance member 'name' was not a string"); }); - 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 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"); + }); + }); + + describe("#id", () => { + it("should be defined", () => { + assert.ok("id" in new SchemaDefinition(...Object.values(params)), + "Instance member 'id' was not defined"); }); - 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 be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.id === "string", + "Instance member 'id' was not a string"); }); - it("should have instance member 'attributes' that is an array", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); - - 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 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"); + }); + }); + + describe("#description", () => { + it("should be defined", () => { + assert.ok("description" in new SchemaDefinition(...Object.values(params)), + "Instance member 'description' was not defined"); + }); + + it("should be a string", () => { + assert.ok(typeof new SchemaDefinition(...Object.values(params))?.description === "string", + "Instance member 'description' was not a string"); + }); + + 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"); + }); + }); + + describe("#attributes", () => { + it("should be defined", () => { + assert.ok("attributes" in new SchemaDefinition(...Object.values(params)), + "Instance member 'attributes' was not defined"); }); - 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 be an array", () => { + assert.ok(Array.isArray(new SchemaDefinition(...Object.values(params))?.attributes), + "Instance member 'attributes' was not an array"); + }); + }); + + describe("#describe()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).describe === "function", + "Instance method 'describe' was not implemented"); + }); + + it("should produce valid SCIM schema definition objects", async () => { + const {describe: suite} = await fixtures; - it("should produce valid SCIM schema definition objects", async () => { - let {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)) + ); - for (let fixture of suite) { - let definition = new SCIMMY.Types.SchemaDefinition( - fixture.source.name, fixture.source.id, fixture.source.description, - fixture.source.attributes.map((a) => instantiateFromFixture(SCIMMY, 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.deepStrictEqual(JSON.parse(JSON.stringify(definition.describe())), fixture.target, + `SchemaDefinition 'describe' fixture #${suite.indexOf(fixture)+1} did not produce valid SCIM schema definition object`); + } + }); + }); + + describe("#attribute()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).attribute === "function", + "Instance method 'attribute' was not implemented"); }); - 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 SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("id"); - it("should find attributes by name", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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'"); - }); + 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 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 ignore case of 'name' argument when finding attributes", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("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"); - }); + 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", () => { + const definition = new SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); - it("should ignore case of 'name' argument when finding attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - attribute = definition.attribute("id"); - - assert.strictEqual(definition.attribute("ID"), attribute, - "Instance method 'attribute' did not ignore case of 'name' argument when finding attributes"); - }); + 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'"); + }); + + it("should expect sub-attributes to exist", () => { + const definition = new SchemaDefinition(...Object.values(params)); - it("should find sub-attributes by name", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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'"); - }); + 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 SchemaDefinition(...Object.values(params)); + const attribute = definition.attribute("meta.resourceType"); - it("should expect sub-attributes to exist", () => { - let 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"); - }); + 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 ignore case of 'name' argument when finding sub-attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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.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 find namespaced attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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.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 expect namespaced attributes to exist", () => { - let 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.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 be implemented", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).extend === "function", + "Instance method 'extend' was not implemented"); + }); + + 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 ignore case of 'name' argument when finding namespaced attributes", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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.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"); }); - 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 all attribute extensions to have unique names", () => { + const definition = new SchemaDefinition(...Object.values(params)); - 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)); - - 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(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 expect all attribute extensions to have unique names", () => { - let 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.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 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"); - - 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.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 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); - - 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.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension, + "Instance method 'extend' did not ignore already declared SchemaDefinition extension"); + }); + }); + + describe("#truncate()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).truncate === "function", + "Instance method 'truncate' was not implemented"); + }); + + 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 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); - - assert.strictEqual(Object.getPrototypeOf(definition.extend(extension).attribute(extension.id)), extension, - "Instance method 'extend' did not ignore already declared SchemaDefinition extension"); - }); + assert.deepStrictEqual(actual, expected, + "Instance method 'truncate' modified definition without arguments"); }); - 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"); + 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 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 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' modified attributes without arguments"); + "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())); - 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())); - 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())); + 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 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"); + }); + }); + + context("when 'targets' argument contains a string", () => { 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 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"); + }); + it("should expect named attributes and sub-attributes to exist", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)); + const definition = new SchemaDefinition(...Object.values(params)); assert.throws(() => definition.truncate("test"), {name: "TypeError", message: `Schema definition '${params.id}' does not declare attribute 'test'`}, @@ -319,80 +418,170 @@ export let SchemaDefinitionSuite = (SCIMMY) => { "Instance method 'truncate' did not expect named sub-attribute 'meta.test' to exist"); }); }); + }); + + describe("#coerce()", () => { + it("should be implemented", () => { + assert.ok(typeof (new SchemaDefinition(...Object.values(params))).coerce === "function", + "Instance method 'coerce' was not implemented"); + }); - 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", () => { - let 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", () => { - let definition = new SCIMMY.Types.SchemaDefinition(...Object.values(params)), - 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 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]); - it("should expect coerce to be called on directly included attributes", () => { - let 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'"); - }); + // 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'"); - it("should expect namespaced attributes or extensions to be coerced", () => { - let 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"); - }); + // 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"}; - it("should expect 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", "testName"), new SCIMMY.Types.Attribute("string", "testValue") - ]), - 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(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 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 '${extension.id}'`}, + "Instance method 'coerce' did not attempt to coerce required schema extension"); + 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"); + }); + + 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.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({[`${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); + const result = definition.coerce({testName: "a string", testValue: "another string"}, undefined, undefined, new 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 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 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.deepStrictEqual(JSON.parse(JSON.stringify(actual)), expected, + "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 1906588..b6b1be5 100644 --- a/test/lib/types/error.js +++ b/test/lib/types/error.js @@ -1,38 +1,49 @@ import assert from "assert"; +import {SCIMError} from "#@/lib/types/error.js"; -export let ErrorSuite = (SCIMMY) => { - it("should include static class 'Error'", () => - assert.ok(!!SCIMMY.Types.Error, "Static class 'Error' not defined")); +describe("SCIMMY.Types.Error", () => { + it("should extend native 'Error' class", () => { + assert.ok(new SCIMError() instanceof Error, + "Error type class did not extend native 'Error' class"); + }); - describe("SCIMMY.Types.Error", () => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new SCIMMY.Types.Error(), + describe("@constructor", () => { + it("should not require arguments", () => { + assert.doesNotThrow(() => new SCIMError(), "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"); + }); + + describe("#name", () => { + it("should be defined", () => { + assert.ok("name" in new SCIMError(), + "Instance member 'name' was not defined"); }); - 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 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 SCIMMY.Types.Error()), - "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 SCIMMY.Types.Error()), - "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 SCIMMY.Types.Error()), - "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 +}); \ No newline at end of file diff --git a/test/lib/types/filter.js b/test/lib/types/filter.js index 9783704..9c11f27 100644 --- a/test/lib/types/filter.js +++ b/test/lib/types/filter.js @@ -2,119 +2,309 @@ import {promises as fs} from "fs"; import path from "path"; import url from "url"; import assert from "assert"; +import {Filter} from "#@/lib/types/filter.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)); - - it("should include static class 'Filter'", () => - assert.ok(!!SCIMMY.Types.Filter, "Static class 'Filter' not defined")); +// 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)); + +describe("SCIMMY.Types.Filter", () => { + it("should extend native 'Array' class", () => { + assert.ok(new Filter("id pr") instanceof Array, + "Filter type class did not extend native 'Array' class"); + }); - describe("SCIMMY.Types.Filter", () => { - it("should not require arguments at instantiation", () => { - assert.doesNotThrow(() => new SCIMMY.Types.Filter(), + describe("@constructor", () => { + it("should not require arguments", () => { + assert.doesNotThrow(() => new Filter("id pr"), "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"); + context("when 'expression' argument is defined", () => { + const fixtures = [ + ["a primitive number", "number value '1'", 1], + ["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 of objects in Filter constructor"}, + `Filter type class did not reject 'expression' parameter ${descriptor}`); + }); + } }); - describe("#constructor", () => { - it("should expect 'expression' argument to be a non-empty string or collection of objects", () => { - let fixtures = [ - ["number value '1'", 1], - ["boolean value 'false'", false] - ]; - - assert.throws(() => new SCIMMY.Types.Filter(""), + 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 [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"), + 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"); }); - it("should expect all grouping operators to be opened and closed in filter string expression", () => { - assert.throws(() => new SCIMMY.Types.Filter("[id pr"), + 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 SCIMMY.Types.Filter("id pr]"), + 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 SCIMMY.Types.Filter("(id pr"), + 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 SCIMMY.Types.Filter("id pr)"), + 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 () => { - let {parse: {simple: suite}} = await fixtures; + it("should parse simple expressions without logical or grouping operators", async function () { + const {parse: {simple: suite}} = await fixtures; - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, + 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 () => { - let {parse: {logical: suite}} = await fixtures; + it("should parse expressions with logical operators", async function () { + const {parse: {logical: suite}} = await fixtures; - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, + 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 () => { - let {parse: {grouping: suite}} = await fixtures; + it("should parse expressions with grouping operators", async function () { + const {parse: {grouping: suite}} = await fixtures; - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, + 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 () => { - let {parse: {complex: suite}} = await fixtures; + it("should parse complex expressions with a mix of logical and grouping operators", async function () { + const {parse: {complex: suite}} = await fixtures; - for (let fixture of suite) { - assert.deepStrictEqual([...new SCIMMY.Types.Filter(fixture.source)], fixture.target, + 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}'`); } }); }); - describe(".match()", () => { - it("should have instance method 'match'", () => { - assert.ok(typeof (new SCIMMY.Types.Filter()).match === "function", - "Instance method 'match' not defined"); + 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 match values for a given filter expression", async () => { - let {match: {source, targets: suite}} = await fixtures; + 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", () => { + 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"); + }); + + 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}'`); + } + }); + }); + + 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"); + }); + + 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; - 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}`); + 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)}'`); + } + }); + }); + }); + + describe("#match()", () => { + it("should be implemented", () => { + assert.ok(typeof (new Filter("id pr")).match === "function", + "Instance method 'match' not implemented"); }); + + 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"], + ["unknown", "not match unknown comparators"] + ]; + + for (let [key, label] of targets) { + it(`should ${label}`, async function () { + const {match: {source, targets: {[key]: suite}}} = await fixtures; + + 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)}'`); + } + }); + } }); -} \ No newline at end of file +}); \ No newline at end of file diff --git a/test/lib/types/filter.json b/test/lib/types/filter.json index f14f81e..eef7c89 100644 --- a/test/lib/types/filter.json +++ b/test/lib/types/filter.json @@ -2,55 +2,344 @@ "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": "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": "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": "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": "name.formatted sw \"Bob\" and name.honoraryPrefix eq \"Mr\"", "target": [{"name": {"formatted": ["sw", "Bob"], "honoraryPrefix": ["eq", "Mr"]}}]} + {"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": "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": "NOt not sw \"A\" and userName ew \"Z\" and userName co \"m\"", "target": [{"not": ["not", "sw", "A"], "userName": [["ew", "Z"], ["co", "m"]]}]} ], "grouping": [ { "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": "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": [ + {"emails": {"type": ["eq", "work"], "value": ["ew", "@example.org"]}}, + {"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": [{"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"]}} + ] + }, + { + "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": [ { "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"]}} + ] + }, + { + "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 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": [ + {"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"]}} + ] + } + ] + }, + "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": [ - {"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]} - ] + "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]}, + {"expression": {"userName": ["np"]}, "expected": []}, + {"expression": {"exists": ["np"]}, "expected": [2, 4]} + ], + "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]} + ], + "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]} + ], + "unknown": [ + {"expression": {"userName": ["un", "AdeleV"]}, "expected": []} + ] + } } } \ No newline at end of file diff --git a/test/lib/types/resource.js b/test/lib/types/resource.js index 0102d46..67f297f 100644 --- a/test/lib/types/resource.js +++ b/test/lib/types/resource.js @@ -1,110 +1,207 @@ import assert from "assert"; +import {Resource} from "#@/lib/types/resource.js"; +import {Filter} from "#@/lib/types/filter.js"; +import {createSchemaClass} from "../../hooks/schemas.js"; +import {createResourceClass} from "../../hooks/resources.js"; -export let ResourceSuite = (SCIMMY) => { - it("should include static class 'Resource'", () => - assert.ok(!!SCIMMY.Types.Resource, "Static class 'Resource' not defined")); +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", + `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}' was 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"); + for (let method of ["basepath", "ingress", "egress", "degress"]) { + describe(`.${method}()`, () => { + it("should be defined", () => { + assert.ok(typeof Resource[method] === "function", + `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}' was 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"); + } + + describe(".extend()", () => { + it("should be implemented", () => { + assert.ok(typeof Resource.extend === "function", + "Static method 'extend' was not implemented"); }); - - 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"); + }); + + describe(".describe()", () => { + it("should be implemented", () => { + assert.ok(typeof Resource.describe === "function", + "Static method 'describe' was not implemented"); }); - 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"); - }); + const TestResource = createResourceClass(); + const properties = [ + ["name"], ["description"], ["id", "name"], ["schema", "id"], + ["endpoint", "name", `/${TestResource.schema.definition.name}`, ", with leading forward-slash"] + ]; - 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"); - }); + 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, + `Static method 'describe' returned '${prop}' property with unexpected value`); + }); + } - 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 expect 'schemaExtensions' property to be excluded in description when resource is not extended", () => { + assert.strictEqual(TestResource.describe().schemaExtensions, undefined, + "Static method 'describe' unexpectedly included 'schemaExtensions' property in description"); }); - 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 expect 'schemaExtensions' property to be included in description when resource is extended", function () { + try { + TestResource.extend(createSchemaClass({name: "Extension", id: "urn:ietf:params:scim:schemas:Extension"})); + } catch { + this.skip(); + } + + assert.ok(!!TestResource.describe().schemaExtensions, + "Static method 'describe' did not include 'schemaExtensions' property in description"); + assert.deepStrictEqual(TestResource.describe().schemaExtensions, [{schema: "urn:ietf:params:scim:schemas:Extension", required: false}], + "Static method 'describe' included 'schemaExtensions' property with unexpected value in description"); }); - - 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"); + }); + + describe("#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 Filter"); }); - - 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"); + }); + + describe("#attributes", () => { + context("when 'excludedAttributes' query parameter was defined", () => { + 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 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"); + }); }); - 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"); + context("when 'attributes' query parameter was defined", () => { + 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 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", []] + ]; - 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"); - }); + 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`); + }); + + 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}); - describe(".extend()", () => { - it("should have static method 'extend'", () => { - assert.ok(typeof SCIMMY.Types.Resource.extend === "function", - "Static method 'extend' not defined"); + 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})); + + assert.deepStrictEqual(resource.constraints, expected, + `Instance member 'constraints' did not include valid properties when '${param}' query parameter had invalid ${label}`); + }); + } }); - }); - - describe(".describe()", () => { - it("should have static method 'describe'", () => { - assert.ok(typeof SCIMMY.Types.Resource.describe === "function", - "Static method 'describe' not defined"); + } + }); + + for (let method of ["read", "write", "patch", "dispose"]) { + describe(`#${method}()`, () => { + it("should be defined", () => { + assert.ok(typeof (new Resource())[method] === "function", + `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}' was not abstract`); }); }); - }); -} \ 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..de449c0 100644 --- a/test/lib/types/schema.js +++ b/test/lib/types/schema.js @@ -1,30 +1,55 @@ +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"; -export let SchemaSuite = (SCIMMY) => { - 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(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"); +// 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", () => { + assert.ok(typeof Object.getOwnPropertyDescriptor(Schema, "definition").get === "function", + "Static member 'definition' was not defined"); }); - describe(".extend()", () => { - it("should have static method 'extend'", () => { - assert.ok(typeof SCIMMY.Types.Schema.extend === "function", - "Static method 'extend' 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 be implemented", () => { + assert.ok(typeof Schema.extend === "function", + "Static method 'extend' was not implemented"); + }); + }); + + describe(".truncate()", () => { + it("should be implemented", () => { + assert.ok(typeof Schema.truncate === "function", + "Static method 'truncate' was not implemented"); }); + }); + + describe("@constructor", () => { + SchemasHooks.construct(createSchemaClass({attributes: [new Attribute("string", "aString")]}), fixtures).call(); - describe(".truncate()", () => { - it("should have static method 'truncate'", () => { - assert.ok(typeof SCIMMY.Types.Schema.truncate === "function", - "Static method 'truncate' not defined"); - }); + 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 +}); \ 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 diff --git a/test/scimmy.js b/test/scimmy.js index 029a99e..f2e0366 100644 --- a/test/scimmy.js +++ b/test/scimmy.js @@ -1,14 +1,29 @@ -import SCIMMY from "../src/scimmy.js"; -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(SCIMMY); - TypesSuite(SCIMMY); - MessagesSuite(SCIMMY); - SchemasSuite(SCIMMY); - ResourcesSuite(SCIMMY); -}) \ No newline at end of file + 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