From b01adb7a1113ef622fe1f47f1f36ad6f7945d49b Mon Sep 17 00:00:00 2001 From: Digitalone Date: Thu, 19 Jan 2023 18:25:32 +0100 Subject: [PATCH 01/10] names table: foreign pointer key ON DELETE SET NULL --- scripts/database/create_names_table.sql | 20 +++++++++++++++++-- .../migrations/0001-initial-migration.sql | 5 +++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/scripts/database/create_names_table.sql b/scripts/database/create_names_table.sql index 5209162f..45cbac7d 100644 --- a/scripts/database/create_names_table.sql +++ b/scripts/database/create_names_table.sql @@ -4,10 +4,26 @@ CREATE TABLE names ( name VARCHAR(128) NOT NULL PRIMARY KEY, - pointer UUID NOT NULL REFERENCES packages(pointer), + pointer UUID NULL, -- constraints - CONSTRAINT lowercase_names CHECK (name = LOWER(name)) + CONSTRAINT lowercase_names CHECK (name = LOWER(name)), + CONSTRAINT package_names_fkey FOREIGN KEY (pointer) REFERENCES packages(pointer) ON DELETE SET NULL ); -- Lowercase constraint added upon the following issue: -- https://github.com/confused-Techie/atom-backend/issues/90 + +/* +-- `pointer` was NOT NULL, then we made it nullable. +-- The previous foreign key has been dropped and a new `package_names_fkey` +-- has need added to avoid supply chain attacks. +-- `pointer` is set to NULL when a row in packages table is deleted. +-- Steps made to apply this change: + +ALTER TABLE names ALTER COLUMN pointer DROP NOT NULL; + +ALTER TABLE names DROP CONSTRAINT previous_foreign_key_name; + +ALTER TABLE names +ADD CONSTRAINT package_names_fkey FOREIGN KEY (pointer) REFERENCES packages(pointer) ON DELETE SET NULL; +*/ diff --git a/src/dev-runner/migrations/0001-initial-migration.sql b/src/dev-runner/migrations/0001-initial-migration.sql index d77e6b44..84200d9d 100644 --- a/src/dev-runner/migrations/0001-initial-migration.sql +++ b/src/dev-runner/migrations/0001-initial-migration.sql @@ -47,9 +47,10 @@ EXECUTE PROCEDURE now_on_updated_package(); CREATE TABLE names ( name VARCHAR(128) NOT NULL PRIMARY KEY, - pointer UUID NOT NULL REFERENCES packages(pointer), + pointer UUID NULL, -- constraints - CONSTRAINT lowercase_names CHECK (name = LOWER(name)) + CONSTRAINT lowercase_names CHECK (name = LOWER(name)), + CONSTRAINT package_names_fkey FOREIGN KEY (pointer) REFERENCES packages(pointer) ON DELETE SET NULL ); -- Create users Table From 688877b53c1fe90c416221299d506b2d32869afc Mon Sep 17 00:00:00 2001 From: Digitalone Date: Thu, 19 Jan 2023 22:36:08 +0100 Subject: [PATCH 02/10] post new package: check if a name is available before publication --- src/database.js | 38 +++++++++++++++++++ .../migrations/0001-initial-migration.sql | 18 ++++----- src/handlers/package_handler.js | 27 ++++++------- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/src/database.js b/src/database.js index f480473d..819032f3 100644 --- a/src/database.js +++ b/src/database.js @@ -66,6 +66,43 @@ async function shutdownSQL() { } } +/** + * @async + * @function packageNameAvailability + * @desc Determines if a name is ready to be used for a new package. Useful in the stage of the publication + * of a new package where checking if the package exists is not enough because a name could be not + * available if a deleted package was using it in the past. + * This function simply checks if the provided name is present in "names" table. + * @param {string} name - The candidate name for a new package. + * @returns {object} A Server Status Object. + */ +async function packageNameAvailability(name) { + try { + sqlStorage ??= setupSQL(); + + const command = await sqlStorage` + SELECT name FROM names + WHERE name = ${name}; + `; + + return command.count === 0 + ? { ok: true, content: `${name} is available to be used for a new package.` } + : { + ok: false, + content: `${name} is not available to be used for a new package.`, + short: "Not Found", + }; + } catch (err) { + return { + ok: false, + content: "Generic Error", + short: "Server Error", + error: err, + }; + } +} + + /** * @async * @function insertNewPackage @@ -1714,6 +1751,7 @@ async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { module.exports = { shutdownSQL, + packageNameAvailability, insertNewPackage, getPackageByName, getPackageCollectionByName, diff --git a/src/dev-runner/migrations/0001-initial-migration.sql b/src/dev-runner/migrations/0001-initial-migration.sql index 84200d9d..9687b7a5 100644 --- a/src/dev-runner/migrations/0001-initial-migration.sql +++ b/src/dev-runner/migrations/0001-initial-migration.sql @@ -56,20 +56,20 @@ CREATE TABLE names ( -- Create users Table CREATE TABLE users ( - id SERIAL PRIMARY KEY, - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - username VARCHAR(256) NOT NULL UNIQUE, - node_id VARCHAR(256) UNIQUE, - avatar VARCHAR(100), - data JSONB + id SERIAL PRIMARY KEY, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + username VARCHAR(256) NOT NULL UNIQUE, + node_id VARCHAR(256) UNIQUE, + avatar VARCHAR(100), + data JSONB ); -- Create stars Table CREATE TABLE stars ( - package UUID NOT NULL REFERENCES packages(pointer), - userid INTEGER NOT NULL REFERENCES users(id), - PRIMARY KEY (package, userid) + package UUID NOT NULL REFERENCES packages(pointer), + userid INTEGER NOT NULL REFERENCES users(id), + PRIMARY KEY (package, userid) ); -- Create versions Table diff --git a/src/handlers/package_handler.js b/src/handlers/package_handler.js index b82d5c5b..a1c12992 100644 --- a/src/handlers/package_handler.js +++ b/src/handlers/package_handler.js @@ -117,13 +117,10 @@ async function postPackages(req, res) { return; } - // Check the package does NOT exists. - // We will utilize our database.getPackageByName to see if it returns an error, - // which means the package doesn't exist. // Currently though the repository is in `owner/repo` format, - // meanwhile getPackageByName expects just `repo` + // meanwhile needed functions expects just `repo` - const repo = params.repository.split("/")[1]; + const repo = params.repository.split("/")[1]?.toLowerCase(); if (repo === undefined) { logger.generic(6, "Repository determined invalid after failed split"); @@ -147,23 +144,27 @@ async function postPackages(req, res) { return; } - const exists = await database.getPackageByName(repo, true); + // Check the package does NOT exists. + // We will utilize our database.packageNameAvailability to see if the name is available. + + const nameAvailable = await database.packageNameAvailability(repo); - if (exists.ok) { - logger.generic(6, "Seems Package Already exists, aborting publish"); + if (!nameAvailable.ok) { + logger.generic(6, "The name for the package is not available: aborting publish"); // The package exists. await common.packageExists(req, res); return; } - // Even further though we need to check that the error is not found, since errors here can bubble. - if (exists.short !== "Not Found") { + // Even further though we need to check that the error is not "Not Found", + // since an exception could have been caught. + if (nameAvailable.short !== "Not Found") { logger.generic( 3, - `postPackages-getPackageByName Not OK: ${exists.content}` + `postPackages-getPackageByName Not OK: ${nameAvailable.content}` ); // The server failed for some other bubbled reason, and is now encountering an error. - await common.handleError(req, res, exists); + await common.handleError(req, res, nameAvailable); return; } @@ -619,7 +620,7 @@ async function postPackagesVersion(req, res) { if (!packExists.ok) { logger.generic( 6, - "Seems Package exists when trying to publish new version" + "Seems Package does not exist when trying to publish new version" ); await common.handleError(req, res, packExists); return; From b93e0e2dcee77b12c8e3964931c45eec06a62ccf Mon Sep 17 00:00:00 2001 From: Digitalone Date: Thu, 19 Jan 2023 22:49:23 +0100 Subject: [PATCH 03/10] check name availability also on rename --- src/database.js | 1 + src/handlers/package_handler.js | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/database.js b/src/database.js index 819032f3..90149fee 100644 --- a/src/database.js +++ b/src/database.js @@ -72,6 +72,7 @@ async function shutdownSQL() { * @desc Determines if a name is ready to be used for a new package. Useful in the stage of the publication * of a new package where checking if the package exists is not enough because a name could be not * available if a deleted package was using it in the past. + * Useful also to check if a name is available for the renaming of a published package. * This function simply checks if the provided name is present in "names" table. * @param {string} name - The candidate name for a new package. * @returns {object} A Server Status Object. diff --git a/src/handlers/package_handler.js b/src/handlers/package_handler.js index a1c12992..d99e77e0 100644 --- a/src/handlers/package_handler.js +++ b/src/handlers/package_handler.js @@ -726,7 +726,7 @@ async function postPackagesVersion(req, res) { const isBanned = await utils.isPackageNameBanned(newName); if (isBanned.ok) { - logger.generic(3, `postPackages Blocked by banned package name: ${repo}`); + logger.generic(3, `postPackages Blocked by banned package name: ${newName}`); // is banned await common.handleError(req, res, { ok: false, @@ -736,6 +736,20 @@ async function postPackagesVersion(req, res) { // TODO ^^^ Replace with specific error once more are supported. return; } + + const isAvailable = await database.packageNameAvailability(newName); + + if (isAvailable.ok) { + logger.generic(3, `postPackages Blocked by new name ${newName} not available`); + // is banned + await common.handleError(req, res, { + ok: false, + short: "Server Error", + content: "Package Name is Not Available", + }); + // TODO ^^^ Replace with specific error once more are supported. + return; + } } // Now add the new Version key. From 95b246279214063a6eae8f2c26ef0f30cc35693d Mon Sep 17 00:00:00 2001 From: Digitalone Date: Thu, 19 Jan 2023 22:56:52 +0100 Subject: [PATCH 04/10] delete package: do not remove names --- src/database.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/database.js b/src/database.js index 90149fee..57b6fe4a 100644 --- a/src/database.js +++ b/src/database.js @@ -72,7 +72,7 @@ async function shutdownSQL() { * @desc Determines if a name is ready to be used for a new package. Useful in the stage of the publication * of a new package where checking if the package exists is not enough because a name could be not * available if a deleted package was using it in the past. - * Useful also to check if a name is available for the renaming of a published package. + * Useful also to check if a name is available for the renaming of a published package. * This function simply checks if the provided name is present in "names" table. * @param {string} name - The candidate name for a new package. * @returns {object} A Server Status Object. @@ -867,7 +867,7 @@ async function removePackageByName(name) { // No check on deleted stars because the package could also have 0 stars. }*/ - // Remove names related to the package + /* We do not remove the package names to avoid supply chain attacks. const commandName = await sqlTrans` DELETE FROM names WHERE pointer = ${pointer} @@ -877,6 +877,7 @@ async function removePackageByName(name) { if (commandName.count === 0) { throw `Failed to delete names for: ${name}`; } + */ const commandPack = await sqlTrans` DELETE FROM packages From 38421a7110875cfcbdea4c30440ab86ef3f2d224 Mon Sep 17 00:00:00 2001 From: Digitalone Date: Thu, 19 Jan 2023 23:03:12 +0100 Subject: [PATCH 05/10] test package name availability --- src/tests_integration/database.test.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/tests_integration/database.test.js b/src/tests_integration/database.test.js index 607cbf53..9aab9594 100644 --- a/src/tests_integration/database.test.js +++ b/src/tests_integration/database.test.js @@ -91,13 +91,17 @@ describe("Package Lifecycle Tests", () => { test("Package A Lifecycle", async () => { const pack = require("./fixtures/lifetime/package-a.js"); + // === Is the package name available? + const nameIsAvailable = await database.packageNameAvailability("package-a-lifetime"); + expect(nameIsAvailable.ok).toBeTruthy(); + // === Let's publish our package const publish = await database.insertNewPackage(pack.createPack); expect(publish.ok).toBeTruthy(); expect(typeof publish.content === "string").toBeTruthy(); // this endpoint only returns a pointer on success. - // === Do we get all the right data back when asking for our package + // === Do we get all the right data back when asking for our package? const getAfterPublish = await database.getPackageByName( pack.createPack.name ); @@ -405,6 +409,10 @@ describe("Package Lifecycle Tests", () => { const ghostPack = await database.getPackageByName(NEW_NAME); expect(ghostPack.ok).toBeFalsy(); expect(ghostPack.short).toEqual("Not Found"); + + // === Is the name of the deleted package available? + const deletedNameAvailable = await database.packageNameAvailability("package-a-lifetime"); + expect(deletedNameAvailable.ok).toBeFalsy(); }); test("User A Lifecycle Test", async () => { const user = require("./fixtures/lifetime/user-a.js"); From cf83b4a5aa396b95d4c48bede925b904bf1cd75c Mon Sep 17 00:00:00 2001 From: Digitalone Date: Sat, 21 Jan 2023 14:59:46 +0100 Subject: [PATCH 06/10] remove unneeded script for duplicated versions --- scripts/tools/duplicateVersions.js | 86 ------------------------------ 1 file changed, 86 deletions(-) delete mode 100644 scripts/tools/duplicateVersions.js diff --git a/scripts/tools/duplicateVersions.js b/scripts/tools/duplicateVersions.js deleted file mode 100644 index a7c75c50..00000000 --- a/scripts/tools/duplicateVersions.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * This file is being created to help us determine the way to strengthen our - * sorting of versions. - * Since after recent changes, we intended to update the production database - * only to find out that several packages weren't compatible. - * This script serves as a method to help determine the scope of incompatibility - * within the production database. - * - * - * This file should be run with: - * - First export setupSQL from database.js - * - Run on the CLI with: `node ./scripts/tools/duplicateVersions.js` - */ - -const database = require("../../src/database.js"); - -sqlStorage = database.setupSQL(); - -async function checkDuplicates() { - try { - const command = await sqlStorage` - SELECT p.pointer, p.name, v.semver_v1, v.semver_v2, v.semver_v3, COUNT(*) AS vcount - FROM packages p INNER JOIN versions V ON p.pointer = v.package - GROUP BY p.pointer, v.semver_v1, v.semver_v2, v.semver_v3 - HAVING COUNT(*) > 1 - ORDER BY vcount DESC, p.name, v.semver_v1, v.semver_v2, v.semver_v3; - `; - - if (command.count === 0) { - return "No packages with duplicated versions.\n"; - } - - let str = ""; - let packs = []; - - for (const v of command) { - const latest = await latestVersion(v.pointer); - - if (latest === "") { - str += `Cannot retrieve latest version for ${v.name} package.\n`; - continue; - } - - const semver = `${v.semver_v1}.${v.semver_v2}.${v.semver_v3}`; - const isLatest = semver === latest; - - str += `Version ${semver} of package ${v.name} is ${ - isLatest ? "" : "NOT " - } the latest.\n`; - - if (!packs.includes(v.pointer)) { - packs.push(v.pointer); - } - } - - str += `\n${packs.length} packages have duplicated versions.\n`; - - return str; - } catch (err) { - return err; - } -} - -async function latestVersion(p) { - try { - const command = await sqlStorage` - SELECT semver_v1, semver_v2, semver_v3 - FROM versions - WHERE package = ${p} AND status != 'removed' - ORDER BY semver_v1 DESC, semver_v2 DESC, semver_v3 DESC; - `; - - return command.count !== 0 - ? `${command[0].semver_v1}.${command[0].semver_v2}.${command[0].semver_v3}` - : ""; - } catch (err) { - return ""; - } -} - -(async () => { - let res = await checkDuplicates(); - - console.log(res); - process.exit(0); -})(); From 61e698dbff738ca0ae4c9b0c6da3ba81edc6e10b Mon Sep 17 00:00:00 2001 From: Digitalone Date: Sat, 21 Jan 2023 15:24:16 +0100 Subject: [PATCH 07/10] revert database changes made in #42 --- scripts/database/create_versions_table.sql | 2 +- src/database.js | 37 +++---------------- .../migrations/0001-initial-migration.sql | 2 +- src/tests_integration/database.test.js | 21 +++-------- 4 files changed, 12 insertions(+), 50 deletions(-) diff --git a/scripts/database/create_versions_table.sql b/scripts/database/create_versions_table.sql index 22b387f3..4c13b83f 100644 --- a/scripts/database/create_versions_table.sql +++ b/scripts/database/create_versions_table.sql @@ -21,5 +21,5 @@ CREATE TABLE versions ( (CAST ((regexp_match(semver, '^(\d+)\.(\d+)\.(\d+)'))[3] AS INTEGER)) STORED, -- constraints CONSTRAINT semver2_format CHECK (semver ~ '^\d+\.\d+\.\d+'), - CONSTRAINT unique_pack_version UNIQUE(package, semver_v1, semver_v2, semver_v3) + CONSTRAINT unique_pack_version UNIQUE(package, semver) ); diff --git a/src/database.js b/src/database.js index 57b6fe4a..12221a88 100644 --- a/src/database.js +++ b/src/database.js @@ -349,8 +349,8 @@ async function insertNewPackageVersion(packJSON, packageData, oldName = null) { RETURNING semver, status; `; } catch (e) { - // This occurs when the (package, semver_vx) unique constraint is violated. - throw `Not allowed to publish a version previously deleted for ${packName}`; + // This occurs when the (package, semver) unique constraint is violated. + throw `Not allowed to publish a version already present for ${packName}`; } if (!addVer?.count) { @@ -592,23 +592,11 @@ async function getPackageVersionByNameAndVersion(name, version) { try { sqlStorage ??= setupSQL(); - // We are permissive on the right side of the semver, so if it's stored with an extension - // we can still get it retrieving the semverArray and looking by semver_vx generated columns. - const svArr = utils.semverArray(version); - if (svArr === null) { - return { - ok: false, - content: `Provided version ${version} is not a valid semver.`, - short: "Not Found", - }; - } - const command = await sqlStorage` SELECT v.semver, v.status, v.license, v.engine, v.meta FROM packages p INNER JOIN names n ON (p.pointer = n.pointer AND n.name = ${name}) - INNER JOIN versions v ON (p.pointer = v.package AND v.semver_v1 = ${svArr[0]} AND - v.semver_v2 = ${svArr[1]} AND v.semver_v3 = ${svArr[2]} AND v.status != 'removed'); + INNER JOIN versions v ON (p.pointer = v.package AND v.semver = ${version} AND v.status != 'removed'); `; return command.count !== 0 @@ -937,18 +925,9 @@ async function removePackageVersion(packName, semVer) { const pointer = packID.content.pointer; - const svArr = utils.semverArray(semVer); - if (svArr === null) { - return { - ok: false, - content: `Provided version ${version} is not a valid semver.`, - short: "Not Found", - }; - } - // Retrieve all non-removed versions sorted from latest to older const getVersions = await sqlTrans` - SELECT id, semver, semver_v1, semver_v2, semver_v3, status + SELECT id, semver, status FROM versions WHERE package = ${pointer} AND status != 'removed' ORDER BY semver_v1 DESC, semver_v2 DESC, semver_v3 DESC; @@ -965,13 +944,7 @@ async function removePackageVersion(packName, semVer) { let removeLatest = false; let versionId = null; for (const v of getVersions) { - // Type coercion on the following comparisons because semverArray contains strings - // while PostgreSQL returns versions as integer. - if ( - v.semver_v1 == svArr[0] && - v.semver_v2 == svArr[1] && - v.semver_v3 == svArr[2] - ) { + if (v.semver === semVer) { versionId = v.id; removeLatest = v.status === "latest"; break; diff --git a/src/dev-runner/migrations/0001-initial-migration.sql b/src/dev-runner/migrations/0001-initial-migration.sql index 9687b7a5..adf3aab4 100644 --- a/src/dev-runner/migrations/0001-initial-migration.sql +++ b/src/dev-runner/migrations/0001-initial-migration.sql @@ -93,7 +93,7 @@ CREATE TABLE versions ( (CAST ((regexp_match(semver, '^(\d+)\.(\d+)\.(\d+)'))[3] AS INTEGER)) STORED, -- constraints CONSTRAINT semver2_format CHECK (semver ~ '^\d+\.\d+\.\d+'), - CONSTRAINT unique_pack_version UNIQUE(package, semver_v1, semver_v2, semver_v3) + CONSTRAINT unique_pack_version UNIQUE(package, semver) ); -- Create authstate Table diff --git a/src/tests_integration/database.test.js b/src/tests_integration/database.test.js index 9aab9594..1dc7aef2 100644 --- a/src/tests_integration/database.test.js +++ b/src/tests_integration/database.test.js @@ -267,16 +267,6 @@ describe("Package Lifecycle Tests", () => { ); expect(getOldVerOnly.content.meta.name).toEqual(pack.createPack.name); - // === Can we get a specific version if the provided semver contains an extension? - const getNewVerWithExt = await database.getPackageVersionByNameAndVersion( - NEW_NAME, - `${v1_0_1.version}-beta` - ); - expect(getNewVerWithExt.ok).toBeTruthy(); - expect(getNewVerWithExt.content.status).toEqual("latest"); - expect(getNewVerWithExt.content.semver).toEqual(v1_0_1.version); - expect(getNewVerWithExt.content.meta.name).toEqual(pack.createPack.name); - // === Can we add a download to our package? const downPack = await database.updatePackageIncrementDownloadByName( NEW_NAME @@ -311,14 +301,13 @@ describe("Package Lifecycle Tests", () => { // === Can we delete our newest version? // === Here we append an extension to test if the version is selected in the same way. - const versionWithExt = `${v1_0_1.version}-beta`; const delLatestVer = await database.removePackageVersion( NEW_NAME, - versionWithExt + v1_0_1.version ); expect(delLatestVer.ok).toBeTruthy(); expect(delLatestVer.content).toEqual( - `Removed ${versionWithExt} of ${NEW_NAME} and ${pack.createPack.metadata.version} is the new latest version.` + `Removed ${v1_0_1.version} of ${NEW_NAME} and ${pack.createPack.metadata.version} is the new latest version.` ); // === Is our old version the latest again? @@ -341,7 +330,7 @@ describe("Package Lifecycle Tests", () => { const latestVer = await database.getPackageByName(NEW_NAME); expect(reAddNextVersion.ok).toBeFalsy(); expect(reAddNextVersion.content).toEqual( - `Not allowed to publish a version previously deleted for ${v1_0_1.name}` + `Not allowed to publish a version already present for ${v1_0_1.name}` ); // === Can we delete a version lower than the current latest? @@ -376,7 +365,7 @@ describe("Package Lifecycle Tests", () => { `There's no version ${pack.createPack.metadata.version} to remove for ${NEW_NAME} package` ); - // === Can we add an odd yet valid semver using an extension? + // === Can we add an odd yet valid semver? const oddVer = pack.addVersion("1.2.3-beta.0"); const oddNewVer = await database.insertNewPackageVersion( oddVer, @@ -538,7 +527,7 @@ describe("Manage Login State Keys", () => { expect(deleteDbKey.content).toEqual(stateKey); }); test("Fail when an Unsaved State Key is provided", async () => { - // === Test aa State Key that has not been stored + // === Test a State Key that has not been stored const stateKey = utils.generateRandomString(64); const notFoundDbKey = await database.authCheckAndDeleteStateKey(stateKey); expect(notFoundDbKey.ok).toBeFalsy(); From b89c8ec4a8eb5bad0698fc3644e82d80ee8476bd Mon Sep 17 00:00:00 2001 From: Digitalone Date: Sat, 21 Jan 2023 15:41:34 +0100 Subject: [PATCH 08/10] add updated/created columns for versions table + update trigger --- scripts/database/create_versions_table.sql | 12 ++++++++++++ .../migrations/0001-initial-migration.sql | 18 +++++++----------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/scripts/database/create_versions_table.sql b/scripts/database/create_versions_table.sql index 4c13b83f..4ad80bb9 100644 --- a/scripts/database/create_versions_table.sql +++ b/scripts/database/create_versions_table.sql @@ -11,6 +11,8 @@ CREATE TABLE versions ( semver VARCHAR(256) NOT NULL, license VARCHAR(128) NOT NULL, engine JSONB NOT NULL, + created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, meta JSONB, -- generated columns semver_v1 INTEGER GENERATED ALWAYS AS @@ -23,3 +25,13 @@ CREATE TABLE versions ( CONSTRAINT semver2_format CHECK (semver ~ '^\d+\.\d+\.\d+'), CONSTRAINT unique_pack_version UNIQUE(package, semver) ); + +-- Create a function and a trigger to set the current timestamp +-- in the `updated` column of the updated row. +-- The function now_on_updated_package() is the same defined in +-- the script for the `packages` table. + +CREATE TRIGGER trigger_now_on_updated_versions + BEFORE UPDATE ON versions + FOR EACH ROW +EXECUTE PROCEDURE now_on_updated_package(); diff --git a/src/dev-runner/migrations/0001-initial-migration.sql b/src/dev-runner/migrations/0001-initial-migration.sql index adf3aab4..8661ec19 100644 --- a/src/dev-runner/migrations/0001-initial-migration.sql +++ b/src/dev-runner/migrations/0001-initial-migration.sql @@ -19,17 +19,6 @@ CREATE TABLE packages ( CONSTRAINT lowercase_names CHECK (name = LOWER(name)) ); --- While the following commands have been used in production, they are excluded here --- Because there is no need to modify existing data during server startup - --- UPDATE packages --- SET package_type = 'theme' --- WHERE LOWER(data ->> 'theme') = 'syntax' OR LOWER(data ->> 'theme') = 'ui'; - --- UPDATE packages --- SET package_type = 'package' --- WHERE package_type != 'theme'; - CREATE FUNCTION now_on_updated_package() RETURNS TRIGGER AS $$ BEGIN @@ -82,6 +71,8 @@ CREATE TABLE versions ( status versionStatus NOT NULL, semver VARCHAR(256) NOT NULL, license VARCHAR(128) NOT NULL, + created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, engine JSONB NOT NULL, meta JSONB, -- generated columns @@ -96,6 +87,11 @@ CREATE TABLE versions ( CONSTRAINT unique_pack_version UNIQUE(package, semver) ); +CREATE TRIGGER trigger_now_on_updated_versions + BEFORE UPDATE ON versions + FOR EACH ROW +EXECUTE PROCEDURE now_on_updated_package(); + -- Create authstate Table CREATE TABLE authstate ( From f22d770fcdf5dcdde7f84c655547aba193bcaaaa Mon Sep 17 00:00:00 2001 From: Digitalone Date: Sat, 21 Jan 2023 16:01:10 +0100 Subject: [PATCH 09/10] start to retrive the results without relying on status = latest --- src/database.js | 151 ++++++++++++++++++++------------ src/handlers/package_handler.js | 6 +- 2 files changed, 96 insertions(+), 61 deletions(-) diff --git a/src/database.js b/src/database.js index 12221a88..cc713e6a 100644 --- a/src/database.js +++ b/src/database.js @@ -521,12 +521,12 @@ async function getPackageByName(name, user = false) { } 'status', v.status, 'semver', v.semver, 'license', v.license, 'engine', v.engine, 'meta', v.meta ) - ORDER BY v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC + ORDER BY v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC ) AS versions FROM packages p INNER JOIN names n ON (p.pointer = n.pointer AND n.name = ${name}) INNER JOIN versions v ON (p.pointer = v.package AND v.status != 'removed') - GROUP BY p.pointer, v.package; + GROUP BY p.pointer; `; return command.count !== 0 @@ -632,12 +632,14 @@ async function getPackageCollectionByName(packArray) { // which process the returned content with constructPackageObjectShort(), // we select only the needed columns. const command = await sqlStorage` - SELECT p.data, p.downloads, (p.stargazers_count + p.original_stargazers) AS stargazers_count, v.semver + SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data FROM packages p INNER JOIN names n ON (p.pointer = n.pointer AND n.name IN ${sqlStorage( packArray )}) - INNER JOIN versions v ON (p.pointer = v.package AND v.status = 'latest'); + INNER JOIN versions v ON (p.pointer = v.package AND v.status != 'removed') + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; `; return command.count !== 0 @@ -930,7 +932,7 @@ async function removePackageVersion(packName, semVer) { SELECT id, semver, status FROM versions WHERE package = ${pointer} AND status != 'removed' - ORDER BY semver_v1 DESC, semver_v2 DESC, semver_v3 DESC; + ORDER BY semver_v1 DESC, semver_v2 DESC, semver_v3 DESC, created DESC; `; const versionCount = getVersions.count; @@ -1440,12 +1442,28 @@ async function getStarringUsersByPointer(pointer) { * @function simpleSearch * @description The current Fuzzy-Finder implementation of search. Ideally eventually * will use a more advanced search method. + * @param {string} term - The search term. + * @param {string} dir - String flag for asc/desc order. + * @param {string} sort - The sort method. + * @param {boolean} [themes=false] - Optional Parameter to specify if this should only return themes. * @returns {object} A server status object containing the results and the pagination object. */ async function simpleSearch(term, page, dir, sort, themes = false) { try { sqlStorage ??= setupSQL(); + // Parse the sort method + const orderType = getOrderField(sort, sqlStorage); + + if (orderType === null) { + logger.generic(3, `Unrecognized Sorting Method Provided: ${sort}`); + return { + ok: false, + content: `Unrecognized Sorting Method Provided: ${sort}`, + short: "Server Error", + }; + } + // We obtain the lowercase version of term since names should be in // lowercase format (see atom-backend issue #86). const lcterm = term.toLowerCase(); @@ -1454,22 +1472,25 @@ async function simpleSearch(term, page, dir, sort, themes = false) { const offset = page > 1 ? (page - 1) * limit : 0; const command = await sqlStorage` - SELECT p.data, p.downloads, (p.stargazers_count + p.original_stargazers) AS stargazers_count, - v.semver, COUNT(*) OVER() AS query_result_count - FROM packages p - INNER JOIN names n ON (p.pointer = n.pointer AND n.name LIKE ${ - "%" + lcterm + "%" - }) - INNER JOIN versions AS v ON (p.pointer = v.package AND v.status = 'latest') - ${ - themes === true - ? sqlStorage`WHERE p.package_type = 'theme'` - : sqlStorage`` - } - ORDER BY ${ - sort === "relevance" ? sqlStorage`downloads` : sqlStorage`${sort}` - } - ${dir === "desc" ? sqlStorage`DESC` : sqlStorage`ASC`} + WITH search_query AS ( + SELECT DISTINCT ON (p.name) p.name, p.data, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + v.semver, p.created, v.updated + FROM packages p + INNER JOIN names n ON (p.pointer = n.pointer AND n.name LIKE ${ + "%" + lcterm + "%" + } + ${ + themes === true + ? sqlStorage`AND p.package_type = 'theme'` + : sqlStorage`` + }) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.status != 'removed') + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC + ) + SELECT *, COUNT(*) OVER() AS query_result_count + FROM search_query + ORDER BY ${orderType} ${dir === "desc" ? sqlStorage`DESC` : sqlStorage`ASC`} LIMIT ${limit} OFFSET ${offset}; `; @@ -1490,7 +1511,7 @@ async function simpleSearch(term, page, dir, sort, themes = false) { count: resultCount, page: page < totalPages ? page : totalPages, total: totalPages, - limit, + limit: limit, }, }; } catch (err) { @@ -1543,8 +1564,7 @@ async function getUserCollectionById(ids) { * then reconstruct the JSON as needed. * @param {int} page - Page number. * @param {string} dir - String flag for asc/desc order. - * @param {string} dir - String flag for asc/desc order. - * @param {string} method - The column name the results have to be sorted by. + * @param {string} method - The sort method. * @param {boolean} [themes=false] - Optional Parameter to specify if this should only return themes. * @returns {object} A server status object containing the results and the pagination object. */ @@ -1560,43 +1580,34 @@ async function getSortedPackages(page, dir, method, themes = false) { try { sqlStorage ??= setupSQL(); - let orderType = null; - - switch (method) { - case "downloads": - orderType = "downloads"; - break; - case "created_at": - orderType = "created"; - break; - case "updated_at": - orderType = "updated"; - break; - case "stars": - orderType = "stargazers_count"; - break; - default: - logger.generic(3, `Unrecognized Sorting Method Provided: ${method}`); - return { - ok: false, - content: `Unrecognized Sorting Method Provided: ${method}`, - short: "Server Error", - }; + const orderType = getOrderField(method, sqlStorage); + + if (orderType === null) { + logger.generic(3, `Unrecognized Sorting Method Provided: ${method}`); + return { + ok: false, + content: `Unrecognized Sorting Method Provided: ${method}`, + short: "Server Error", + }; } const command = await sqlStorage` - SELECT p.data, p.downloads, (p.stargazers_count + p.original_stargazers) AS stargazers_count, - v.semver, COUNT(*) OVER() AS query_result_count - FROM packages AS p - INNER JOIN versions AS v ON (p.pointer = v.package AND v.status = 'latest') - ${ - themes === true - ? sqlStorage`WHERE package_type = 'theme'` - : sqlStorage`` - } - ORDER BY ${orderType} ${ - dir === "desc" ? sqlStorage`DESC` : sqlStorage`ASC` - } + WITH latest_versions AS ( + SELECT DISTINCT ON (p.name) p.name, p.data, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + v.semver, p.created, v.updated + FROM packages p + INNER JOIN versions AS v ON (p.pointer = v.package AND v.status != 'removed' + ${ + themes === true + ? sqlStorage`AND p.package_type = 'theme'` + : sqlStorage`` + }) + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC + ) + SELECT *, COUNT(*) OVER() AS query_result_count + FROM latest_versions + ORDER BY ${orderType} ${dir === "desc" ? sqlStorage`DESC` : sqlStorage`ASC`} LIMIT ${limit} OFFSET ${offset}; `; @@ -1626,6 +1637,30 @@ async function getSortedPackages(page, dir, method, themes = false) { } } +/** + * @async + * @function getOrderField + * @description Internal method to parse the sort method and return the related database field/column. + * @param {string} method - The sort method. + * @param {object} sqlStorage - The database class instance used parse the proper field. + * @returns {object|null} The string field associated to the sort method or null if the method is not recognized. + */ +function getOrderField(method, sqlStorage) { + switch (method) { + case "relevance": + case "downloads": + return sqlStorage`downloads`; + case "created_at": + return sqlStorage`created`; + case "updated_at": + return sqlStorage`updated`; + case "stars": + return sqlStorage`stargazers_count`; + default: + return null; + } +} + /** * @async * @function authStoreStateKey diff --git a/src/handlers/package_handler.js b/src/handlers/package_handler.js index d99e77e0..d5feaf89 100644 --- a/src/handlers/package_handler.js +++ b/src/handlers/package_handler.js @@ -270,15 +270,15 @@ async function getPackagesFeatured(req, res) { */ async function getPackagesSearch(req, res) { const params = { - sort: query.sort(req, "relevance"), + sort: query.sort(req), page: query.page(req), direction: query.dir(req), query: query.query(req), }; // Because the task of implementing the custom search engine is taking longer - // than expected, this will instead use super basic text searching on the DB - // side. This is only an effort to get this working quickly and should be changed later. + // than expected, this will instead use super basic text searching on the DB side. + // This is only an effort to get this working quickly and should be changed later. // This also means for now, the default sorting method will be downloads, not relevance. const packs = await database.simpleSearch( From ed8b507d17edacb4f4c537125a6292231542209f Mon Sep 17 00:00:00 2001 From: Digitalone Date: Sat, 21 Jan 2023 23:32:52 +0100 Subject: [PATCH 10/10] more tests on sorting variants + move deletion tests at the end --- src/handlers/theme_handler.js | 2 +- src/tests_integration/main.test.js | 260 +++++++++++++++-------------- 2 files changed, 138 insertions(+), 124 deletions(-) diff --git a/src/handlers/theme_handler.js b/src/handlers/theme_handler.js index 46935cb3..2e1ca06a 100644 --- a/src/handlers/theme_handler.js +++ b/src/handlers/theme_handler.js @@ -112,7 +112,7 @@ async function getThemes(req, res) { */ async function getThemesSearch(req, res) { const params = { - sort: query.sort(req, "relevance"), + sort: query.sort(req), page: query.page(req), direction: query.dir(req), query: query.query(req), diff --git a/src/tests_integration/main.test.js b/src/tests_integration/main.test.js index 8772fc46..68a804be 100644 --- a/src/tests_integration/main.test.js +++ b/src/tests_integration/main.test.js @@ -158,25 +158,23 @@ describe("Get /api/packages", () => { const res = await request(app).get( "/api/packages?page=2&sort=created_at&direction=asc" ); + expect(res).toHaveHTTPCode(200); expect(res.body).toBeArray(); }); - test("Should return valid Status Code", async () => { + test("Should respond with an array of packages sorted by update date.", async () => { const res = await request(app).get( - "/api/packages?page=2&sort=created_at&direction=asc" + "/api/packages?page=2&sort=updated_at&direction=asc" ); expect(res).toHaveHTTPCode(200); - }); - test("Should respond with an array of packages sorted by stars.", async () => { - const res = await request(app).get( - "/api/packages?page=3&sort=stars&direction=desc" - ); expect(res.body).toBeArray(); }); - test("Should return valid Status Code", async () => { + test("Should respond with an array of packages sorted by stars.", async () => { const res = await request(app).get( - "/api/packages?page=3&sort=stars&direction=desc" + "/api/packages?page=1&sort=stars&direction=desc" ); expect(res).toHaveHTTPCode(200); + expect(res.body).toBeArray(); + expect(res.body[0].name).toEqual("atom-material-ui"); }); test("Should return valid Status Code on invalid parameters", async () => { const res = await request(app).get( @@ -336,6 +334,12 @@ describe("GET /api/packages/search", () => { .query({ order: "asc" }); expect(res.body[0].name).toBe("language-css"); }); + test("Has the correct order listing by stars", async () => { + const res = await request(app) + .get("/api/packages/search?q=language") + .query({ sort: "start" }); + expect(res.body[0].name).toBe("language-cpp"); + }); test("Ignores invalid 'direction'", async () => { const res = await request(app) .get("/api/packages/search?q=language") @@ -388,62 +392,6 @@ describe("GET /api/packages/:packageName", () => { }); }); -describe("DELETE /api/packages/:packageName", () => { - test("No Auth, returns 401", async () => { - const res = await request(app).delete("/api/packages/language-css"); - expect(res).toHaveHTTPCode(401); - }); - test("No Auth, returns 'Bad Auth' with no token", async () => { - const res = await request(app).delete("/api/packages/language-css"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns 401 with Invalid Token", async () => { - const res = await request(app) - .delete("/api/packages/language-css") - .set("Authorization", "invalid"); - expect(res).toHaveHTTPCode(401); - }); - test("Returns Bad Auth Msg with Invalid Token", async () => { - const res = await request(app) - .delete("/api/packages/language-css") - .set("Authorization", "invalid"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns Bad Auth Msg with Valid Token, but no repo access", async () => { - const res = await request(app) - .delete("/api/packages/language-css") - .set("Authorization", "no-valid-token"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns Bad Auth Http with Valid Token, but no repo access", async () => { - const res = await request(app) - .delete("/api/packages/language-css") - .set("Authorization", "no-valid-token"); - expect(res).toHaveHTTPCode(401); - }); - test("Returns Success Message & HTTP with Valid Token", async () => { - const res = await request(app) - .delete("/api/packages/atom-material-ui") - .set("Authorization", "admin-token"); - expect(res).toHaveHTTPCode(204); - - const after = await request(app).get("/api/packages"); - // This ensures our deleted package is no longer in the full package list. - expect(after.body).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: "atom-material-ui", - }), - ]) - ); - }); - // The ^^ above ^^ reads: - // * Expect your Array does NOT Equal - // * An Array that contains - // * An Object that Contains - // * The property { name: "atom-material-ui" } -}); - describe("POST /api/packages/:packageName/star", () => { test("Returns 401 with No Auth", async () => { const res = await request(app).post("/api/packages/language-css/star"); @@ -707,64 +655,6 @@ describe("GET /api/packages/:packageName/versions/:versionName/tarball", () => { }); }); -describe("DELETE /api/packages/:packageName/versions/:versionName", () => { - test.todo("Finish these tests"); - test("Returns 401 with No Auth", async () => { - const res = await request(app).delete( - "/api/packages/langauge-css/versions/0.45.7" - ); - expect(res).toHaveHTTPCode(401); - }); - test("Returns Bad Auth Message with No Auth", async () => { - const res = await request(app).delete( - "/api/packages/langauge-css/versions/0.45.7" - ); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns 401 with Bad Auth", async () => { - const res = await request(app) - .delete("/api/packages/language-css/versions/0.45.7") - .set("Authorization", "invalid"); - expect(res).toHaveHTTPCode(401); - }); - test("Returns Bad Auth Message with Bad Auth", async () => { - const res = await request(app) - .delete("/api/packages/langauge-css/versions/0.45.7") - .set("Authorization", "invalid"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns 404 with Bad Package", async () => { - const res = await request(app) - .delete("/api/packages/language-golang/versions/1.0.0") - .set("Authorization", "admin-token"); - expect(res).toHaveHTTPCode(404); - }); - test("Returns Not Found Msg with Bad Package", async () => { - const res = await request(app) - .delete("/api/packages/langauge-golang/versions/1.0.0") - .set("Authorization", "admin-token"); - expect(res.body.message).toEqual(msg.notFound); - }); - test("Returns 404 with Valid Package & Bad Version", async () => { - const res = await request(app) - .delete("/api/packages/language-css/versions/1.0.0") - .set("Authorization", "admin-token"); - expect(res).toHaveHTTPCode(404); - }); - test("Returns Not Found Msg with Valid Package & Bad Version", async () => { - const res = await request(app) - .delete("/api/packages/language-css/versions/1.0.0") - .set("Authorization", "admin-token"); - expect(res.body.message).toEqual(msg.notFound); - }); - test("Returns 204 on Success", async () => { - const res = await request(app) - .delete("/api/packages/language-css/versions/0.45.0") - .set("Authorization", "admin-token"); - expect(res).toHaveHTTPCode(204); - }); -}); - describe("POST /api/packages/:packageName/versions/:versionName/events/uninstall", () => { test.todo("Write all of these"); test("Returns 401 with No Auth", async () => { @@ -923,6 +813,16 @@ describe("GET /api/themes/search", () => { expect(res.headers["query-total"].match(/^\d+$/) === null).toBeFalsy(); expect(res.headers["query-limit"].match(/^\d+$/) === null).toBeFalsy(); }); + test("Has the correct default DESC listing", async () => { + const res = await request(app).get("/api/themes/search?q=material"); + expect(res.body[0].name).toBe("atom-material-ui"); + }); + test("Sets ASC listing correctly", async () => { + const res = await request(app) + .get("/api/themes/search?q=material") + .query({ direction: "asc" }); + expect(res.body[0].name).toBe("atom-material-syntax"); + }); test("Invalid Search Returns Array", async () => { const res = await request(app).get("/api/themes/search?q=not-one-match"); expect(res.body).toBeArray(); @@ -1273,3 +1173,117 @@ describe("Ensure Options Method Returns as Expected", () => { rateLimitHeaderCheck(res); }); }); + +describe("DELETE /api/packages/:packageName/versions/:versionName", () => { + test.todo("Finish these tests"); + test("Returns 401 with No Auth", async () => { + const res = await request(app).delete( + "/api/packages/langauge-css/versions/0.45.7" + ); + expect(res).toHaveHTTPCode(401); + }); + test("Returns Bad Auth Message with No Auth", async () => { + const res = await request(app).delete( + "/api/packages/langauge-css/versions/0.45.7" + ); + expect(res.body.message).toEqual(msg.badAuth); + }); + test("Returns 401 with Bad Auth", async () => { + const res = await request(app) + .delete("/api/packages/language-css/versions/0.45.7") + .set("Authorization", "invalid"); + expect(res).toHaveHTTPCode(401); + }); + test("Returns Bad Auth Message with Bad Auth", async () => { + const res = await request(app) + .delete("/api/packages/langauge-css/versions/0.45.7") + .set("Authorization", "invalid"); + expect(res.body.message).toEqual(msg.badAuth); + }); + test("Returns 404 with Bad Package", async () => { + const res = await request(app) + .delete("/api/packages/language-golang/versions/1.0.0") + .set("Authorization", "admin-token"); + expect(res).toHaveHTTPCode(404); + }); + test("Returns Not Found Msg with Bad Package", async () => { + const res = await request(app) + .delete("/api/packages/langauge-golang/versions/1.0.0") + .set("Authorization", "admin-token"); + expect(res.body.message).toEqual(msg.notFound); + }); + test("Returns 404 with Valid Package & Bad Version", async () => { + const res = await request(app) + .delete("/api/packages/language-css/versions/1.0.0") + .set("Authorization", "admin-token"); + expect(res).toHaveHTTPCode(404); + }); + test("Returns Not Found Msg with Valid Package & Bad Version", async () => { + const res = await request(app) + .delete("/api/packages/language-css/versions/1.0.0") + .set("Authorization", "admin-token"); + expect(res.body.message).toEqual(msg.notFound); + }); + test("Returns 204 on Success", async () => { + const res = await request(app) + .delete("/api/packages/language-css/versions/0.45.0") + .set("Authorization", "admin-token"); + expect(res).toHaveHTTPCode(204); + }); +}); + +describe("DELETE /api/packages/:packageName", () => { + test("No Auth, returns 401", async () => { + const res = await request(app).delete("/api/packages/language-css"); + expect(res).toHaveHTTPCode(401); + }); + test("No Auth, returns 'Bad Auth' with no token", async () => { + const res = await request(app).delete("/api/packages/language-css"); + expect(res.body.message).toEqual(msg.badAuth); + }); + test("Returns 401 with Invalid Token", async () => { + const res = await request(app) + .delete("/api/packages/language-css") + .set("Authorization", "invalid"); + expect(res).toHaveHTTPCode(401); + }); + test("Returns Bad Auth Msg with Invalid Token", async () => { + const res = await request(app) + .delete("/api/packages/language-css") + .set("Authorization", "invalid"); + expect(res.body.message).toEqual(msg.badAuth); + }); + test("Returns Bad Auth Msg with Valid Token, but no repo access", async () => { + const res = await request(app) + .delete("/api/packages/language-css") + .set("Authorization", "no-valid-token"); + expect(res.body.message).toEqual(msg.badAuth); + }); + test("Returns Bad Auth Http with Valid Token, but no repo access", async () => { + const res = await request(app) + .delete("/api/packages/language-css") + .set("Authorization", "no-valid-token"); + expect(res).toHaveHTTPCode(401); + }); + test("Returns Success Message & HTTP with Valid Token", async () => { + const res = await request(app) + .delete("/api/packages/atom-material-ui") + .set("Authorization", "admin-token"); + expect(res).toHaveHTTPCode(204); + + const after = await request(app).get("/api/packages"); + // This ensures our deleted package is no longer in the full package list. + expect(after.body).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "atom-material-ui", + }), + ]) + ); + }); + // The ^^ above ^^ reads: + // * Expect your Array does NOT Equal + // * An Array that contains + // * An Object that Contains + // * The property { name: "atom-material-ui" } +});