diff --git a/.github/workflows/ci-standards.yml b/.github/workflows/ci-standards.yml index 9b90a913..cddece01 100644 --- a/.github/workflows/ci-standards.yml +++ b/.github/workflows/ci-standards.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [16.x] + node-version: [18.x] steps: - name: Checkout the latest code diff --git a/.github/workflows/ci-tests-pull.yml b/.github/workflows/ci-tests-pull.yml index 3d9350b8..7872f602 100644 --- a/.github/workflows/ci-tests-pull.yml +++ b/.github/workflows/ci-tests-pull.yml @@ -39,7 +39,7 @@ jobs: strategy: matrix: - node-version: [16.x] + node-version: [17.x, 18.x, 19.x] steps: - name: Checkout the latest code diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 5d79659a..49d9d154 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -42,7 +42,7 @@ jobs: strategy: matrix: - node-version: [16.x] + node-version: [17.x, 18.x, 19.x] steps: - name: Checkout the latest code diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..3c032078 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 diff --git a/app.example.yaml b/app.example.yaml index 91b3e834..c42eae19 100644 --- a/app.example.yaml +++ b/app.example.yaml @@ -2,7 +2,7 @@ # Name this file app.yaml, and it will be picked up by the server. # app.yaml is in the git ignore to prevent any secrets from leaking. -runtime: nodejs14 +runtime: nodejs18 service: default env_variables: @@ -22,8 +22,6 @@ env_variables: GH_CLIENTID: "" # The Client Secret to accompany the CLIENT ID GH_CLIENTSECRET: "" - # The github username associated with the token. - GH_USERNAME: "" # The User Agent thats used to communicate with GitHub GH_USERAGENT: "Pulsar-Edit Bot" # The URI that the OAuth instance should redirect to. Should end in `/api/oauth` @@ -48,3 +46,5 @@ env_variables: # Determines how Logs are written. Currently supports the following options: # stdout - Writes the console LOG_FORMAT: "stdout" + RATE_LIMIT_GENERIC: 300 + RATE_LIMIT_AUTH: 300 diff --git a/package.json b/package.json index 6e4e8904..65e94f62 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "The Pulsar API Backend for the Community fork of Atom.", "main": "src/server.js", "engines": { - "node": ">=14.0.0 <=16.16.0" + "node": ">=17.9.1 <=18.13.0" }, "scripts": { "start": "node ./src/server.js", 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/scripts/database/create_versions_table.sql b/scripts/database/create_versions_table.sql index 22b387f3..b3e3a8a1 100644 --- a/scripts/database/create_versions_table.sql +++ b/scripts/database/create_versions_table.sql @@ -11,7 +11,10 @@ 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, + deleted BOOLEAN NOT NULL DEFAULT FALSE, -- generated columns semver_v1 INTEGER GENERATED ALWAYS AS (CAST ((regexp_match(semver, '^(\d+)\.(\d+)\.(\d+)'))[1] AS INTEGER)) STORED, @@ -21,5 +24,15 @@ 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 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/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); -})(); diff --git a/src/database.js b/src/database.js index f480473d..4e9106d8 100644 --- a/src/database.js +++ b/src/database.js @@ -7,7 +7,6 @@ const fs = require("fs"); const postgres = require("postgres"); const storage = require("./storage.js"); -const utils = require("./utils.js"); const logger = require("./logger.js"); const { DB_HOST, @@ -66,6 +65,44 @@ 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. + * 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. + */ +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 @@ -81,12 +118,6 @@ async function insertNewPackage(pack) { // All data is committed into the database only if no errors occur. return await sqlStorage .begin(async (sqlTrans) => { - const packData = { - name: pack.name, - repository: pack.repository, - readme: pack.readme, - metadata: pack.metadata, - }; const packageType = typeof pack.metadata.themes === "string" && pack.metadata.themes.match(/^(?:themes|ui)$/i) !== null @@ -98,9 +129,10 @@ async function insertNewPackage(pack) { let insertNewPack = {}; try { // No need to specify downloads and stargazers. They default at 0 on creation. + // TODO: data column deprecated; to be removed insertNewPack = await sqlTrans` INSERT INTO packages (name, creation_method, data, package_type) - VALUES (${pack.name}, ${pack.creation_method}, ${packData}, ${packageType}) + VALUES (${pack.name}, ${pack.creation_method}, ${pack}, ${packageType}) RETURNING pointer; `; } catch (e) { @@ -130,15 +162,12 @@ async function insertNewPackage(pack) { throw `Cannot insert ${pack.name} in names table`; } - // git.createPackage() executed before this function ensures - // the latest version is correctly selected. - const latest = pack.releases.latest; - // Populate versions table let versionCount = 0; const pv = pack.versions; + // TODO: status column deprecated; to be removed. + const status = "published"; for (const ver of Object.keys(pv)) { - const status = ver === latest ? "latest" : "published"; // Since many packages don't define an engine field, // we will do it for them if not present, @@ -192,7 +221,7 @@ async function insertNewPackage(pack) { * @param {string|null} oldName - If provided, the old name to be replaced for the renaming of the package. * @returns {object} A server status object. */ -async function insertNewPackageVersion(packJSON, packageData, oldName = null) { +async function insertNewPackageVersion(packJSON, oldName = null) { sqlStorage ??= setupSQL(); // We are expected to receive a standard `package.json` file. @@ -255,64 +284,27 @@ async function insertNewPackageVersion(packJSON, packageData, oldName = null) { packName = packJSON.name; } - // Here we need to check if the current latest version is lower than the new one - // which we want to publish. - const latestVersion = await sqlTrans` - SELECT id, status, semver - FROM versions - WHERE package = ${pointer} AND status = 'latest'; - `; - - if (latestVersion.count === 0) { - throw `There is no current latest version for ${packName}. The package is broken.`; - } - - const higherSemver = utils.semverGt( - utils.semverArray(packJSON.version), - utils.semverArray(latestVersion[0].semver) - ); - if (!higherSemver) { - throw `Cannot publish a new version with semver lower or equal than the current latest one.`; - } - - // The new version can be published. First switch the current "latest" to "published". - const updateLastVer = await sqlTrans` - UPDATE versions - SET status = 'published' - WHERE id = ${latestVersion[0].id} - RETURNING semver, status; - `; + // We used to check if the new version was higher than the latest, but this is + // too cumbersome to do and the publisher has the responsibility to push an + // higher version to be signaled in Pulsar for the update, so we just try to + // insert whatever we got. + // The only requirement is that the provided semver is not already present + // in the database for the targeted package. - if (updateLastVer.count === 0) { - throw `Unable to modify last published version ${packName}`; - } - - // Then update the package object in the related column of packages table. - const updatePackageData = await sqlTrans` - UPDATE packages - SET data = ${packageData} - WHERE pointer = ${pointer} - RETURNING name; - `; - - if (updatePackageData.count === 0) { - throw `Unable to update the full package object of the new version ${packName}`; - } - - // We can finally insert the new latest version. const license = packJSON.license ?? defaultLicense; const engine = packJSON.engines ?? defaultEngine; let addVer = {}; try { + // TODO: status column deprecated; to be removed addVer = await sqlTrans` INSERT INTO versions (package, status, semver, license, engine, meta) - VALUES(${pointer}, 'latest', ${packJSON.version}, ${license}, ${engine}, ${packJSON}) + VALUES(${pointer}, 'published', ${packJSON.version}, ${license}, ${engine}, ${packJSON}) 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) { @@ -473,22 +465,21 @@ async function getPackageByName(name, user = false) { ${ user ? sqlStorage`` : sqlStorage`p.pointer,` } p.name, p.created, p.updated, p.creation_method, p.downloads, - (p.stargazers_count + p.original_stargazers) AS stargazers_count, p.data, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, JSONB_AGG( JSON_BUILD_OBJECT( ${ user ? sqlStorage`` : sqlStorage`'id', v.id, 'package', v.package,` - } 'status', v.status, 'semver', v.semver, - 'license', v.license, 'engine', v.engine, 'meta', v.meta + } '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; + FROM packages AS p + INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) + GROUP BY p.pointer; `; return command.count !== 0 @@ -554,23 +545,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'); + SELECT v.semver, v.license, v.engine, v.meta + FROM packages AS p + INNER JOIN names AS n ON (p.pointer = n.pointer AND n.name = ${name}) + INNER JOIN versions AS v ON (p.pointer = v.package AND v.semver = ${version} AND v.deleted IS FALSE); `; return command.count !== 0 @@ -606,12 +585,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 - FROM packages p - INNER JOIN names n ON (p.pointer = n.pointer AND n.name IN ${sqlStorage( + SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, v.meta AS data + FROM packages AS p + INNER JOIN names AS 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 AS v ON (p.pointer = v.package AND v.deleted IS FALSE) + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; `; return command.count !== 0 @@ -639,10 +620,12 @@ async function getPackageCollectionByID(packArray) { sqlStorage ??= setupSQL(); const command = await sqlStorage` - SELECT data + SELECT DISTINCT ON (p.name) p.name, v.semver, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, v.meta AS data FROM packages AS p - INNER JOIN versions AS v ON (p.pointer = v.package AND v.status = 'latest') + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE) WHERE pointer IN ${sqlStorage(packArray)} + ORDER BY p.name, v.semver_v1 DESC, v.semver_v2 DESC, v.semver_v3 DESC, v.created DESC; `; return command.count !== 0 @@ -661,7 +644,7 @@ async function getPackageCollectionByID(packArray) { /** * @async * @function updatePackageStargazers - * @description Uses the package name (or pointer if provided) to update its stargazers count. + * @description Internal util that uses the package name (or pointer if provided) to update its stargazers count. * @param {string} name - The package name. * @param {string} pointer - The package id (if given, the search by name is skipped). * @returns {object} The effected server status object. @@ -692,7 +675,7 @@ async function updatePackageStargazers(name, pointer = null) { UPDATE packages SET stargazers_count = ${starCount} WHERE pointer = ${pointer} - RETURNING name, downloads, (stargazers_count + original_stargazers) AS stargazers_count, data; + RETURNING name, (stargazers_count + original_stargazers) AS stargazers_count; `; return updateStar.count !== 0 @@ -724,11 +707,11 @@ async function updatePackageIncrementDownloadByName(name) { sqlStorage ??= setupSQL(); const command = await sqlStorage` - UPDATE packages p + UPDATE packages AS p SET downloads = p.downloads + 1 - FROM names n + FROM names AS n WHERE n.pointer = p.pointer AND n.name = ${name} - RETURNING p.name, p.downloads, p.data; + RETURNING p.name, p.downloads; `; return command.count !== 0 @@ -760,11 +743,11 @@ async function updatePackageDecrementDownloadByName(name) { sqlStorage ??= setupSQL(); const command = await sqlStorage` - UPDATE packages p + UPDATE packages AS p SET downloads = GREATEST(p.downloads - 1, 0) - FROM names n + FROM names AS n WHERE n.pointer = p.pointer AND n.name = ${name} - RETURNING p.name, p.downloads, p.data; + RETURNING p.name, p.downloads; `; return command.count !== 0 @@ -829,7 +812,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} @@ -839,6 +822,7 @@ async function removePackageByName(name) { if (commandName.count === 0) { throw `Failed to delete names for: ${name}`; } + */ const commandPack = await sqlTrans` DELETE FROM packages @@ -872,8 +856,8 @@ async function removePackageByName(name) { /** * @async * @function removePackageVersion - * @description Mark a version of a specific package as removed. This does not delete the record, - * just mark the status as removed, but only if one published version remain available. + * @description Mark a version of a specific package as deleted. This does not delete the record, + * just mark the boolean deleted flag as true, but only if one published version remains available. * This also makes sure that a new latest version is selected in case the previous one is removed. * @param {string} packName - The package name. * @param {string} semVer - The version to remove. @@ -898,72 +882,27 @@ 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 + // Retrieve all non-removed versions to count them const getVersions = await sqlTrans` - SELECT id, semver, semver_v1, semver_v2, semver_v3, status + SELECT id FROM versions - WHERE package = ${pointer} AND status != 'removed' - ORDER BY semver_v1 DESC, semver_v2 DESC, semver_v3 DESC; + WHERE package = ${pointer} AND deleted IS FALSE; `; const versionCount = getVersions.count; - if (versionCount === 0) { - throw `No published version available for ${packName} package`; + if (versionCount < 2) { + throw `${packName} package has less than 2 published versions: deletion not allowed.`; } - // Having all versions, we loop them to find: - // - the id of the version to remove - // - if its status is "latest" - 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] - ) { - versionId = v.id; - removeLatest = v.status === "latest"; - break; - } - } - - if (versionId === null) { - // Do not use throw here because we specify Not Found reason. - return { - ok: false, - content: `There's no version ${semVer} to remove for ${packName} package`, - short: "Not Found", - }; - } - - // We have the version to remove, but for the package integrity we have to make sure that - // at least one published version is still available after the removal. - // This is not possible if the version count is only 1. - if (versionCount === 1) { - throw `It's not possible to leave the ${packName} without at least one published version`; - } - - // The package will have published versions, so we can remove the targeted semVer. - const updateRemovedStatus = await sqlTrans` + // We can remove the targeted semVer. + const markDeletedVersion = await sqlTrans` UPDATE versions - SET status = 'removed' - WHERE id = ${versionId} + SET DELETED = TRUE + WHERE package = ${pointer} AND semver = ${semVer} RETURNING id; `; - if (updateRemovedStatus.count === 0) { + if (markDeletedVersion.count === 0) { // Do not use throw here because we specify Not Found reason. return { ok: false, @@ -972,68 +911,9 @@ async function removePackageVersion(packName, semVer) { }; } - if (!removeLatest) { - // We have removed a published versions and the latest one is still available. - return { - ok: true, - content: `Successfully removed ${semVer} version of ${packName} package.`, - }; - } - - // We have removed the version with the "latest" status, so now we have to select - // a new version between the remaining ones which will obtain "latest" status. - // No need to compare versions here. We have an array ordered from latest to older, - // just pick the first one not equal to semVer - let latestVersionId = null; - let latestSemver = null; - for (const v of getVersions) { - if (v.id === versionId) { - // Skip the removed version - continue; - } - latestVersionId = v.id; - latestSemver = v.semver; - } - - if (latestVersionId === null) { - throw `An error occurred while selecting the highest versions of ${packName}`; - } - - // Mark the targeted highest version as latest. - const commandLatest = await sqlTrans` - UPDATE versions - SET status = 'latest' - WHERE id = ${latestVersionId} - RETURNING id, meta; - `; - - if (commandLatest.count === 0) { - return { - ok: false, - content: `Unable to remove ${semVer} version of ${packName} package.`, - short: "Not Found", - }; - } - - // Let's save the meta data object of the new latest version so - // we can update it inside the packages table since we use the - // packages.data column to report the full object of the lastest version. - const latestDataObject = commandLatest[0].meta; - - const updatePackageData = await sqlTrans` - UPDATE packages - SET data = ${latestDataObject} - WHERE pointer = ${pointer} - RETURNING name; - `; - - if (updatePackageData.count === 0) { - throw `Unable to update the full package object of the new version ${latestSemver}`; - } - return { ok: true, - content: `Removed ${semVer} of ${packName} and ${latestSemver} is the new latest version.`, + content: `Successfully removed ${semVer} version of ${packName} package.`, }; }) .catch((err) => { @@ -1428,12 +1308,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(); @@ -1442,22 +1338,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, v.meta AS data, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + v.semver, p.created, v.updated + FROM packages AS p + INNER JOIN names AS 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.deleted IS FALSE) + 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}; `; @@ -1478,7 +1377,7 @@ async function simpleSearch(term, page, dir, sort, themes = false) { count: resultCount, page: page < totalPages ? page : totalPages, total: totalPages, - limit, + limit: limit, }, }; } catch (err) { @@ -1531,8 +1430,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. */ @@ -1548,43 +1446,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, v.meta AS data, p.downloads, + (p.stargazers_count + p.original_stargazers) AS stargazers_count, + v.semver, p.created, v.updated + FROM packages AS p + INNER JOIN versions AS v ON (p.pointer = v.package AND v.deleted IS FALSE + ${ + 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}; `; @@ -1614,6 +1503,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 @@ -1714,6 +1627,7 @@ async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { module.exports = { shutdownSQL, + packageNameAvailability, insertNewPackage, getPackageByName, getPackageCollectionByName, @@ -1731,7 +1645,6 @@ module.exports = { getPackageVersionByNameAndVersion, updatePackageIncrementDownloadByName, updatePackageDecrementDownloadByName, - updatePackageStargazers, getFeaturedThemes, simpleSearch, updateIncrementStar, diff --git a/src/dev-runner/migrations/0001-initial-migration.sql b/src/dev-runner/migrations/0001-initial-migration.sql index d77e6b44..04578fe4 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 @@ -47,28 +36,29 @@ 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 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 @@ -81,8 +71,11 @@ 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, + deleted BOOLEAN NOT NULL DEFAULT FALSE, -- generated columns semver_v1 INTEGER GENERATED ALWAYS AS (CAST ((regexp_match(semver, '^(\d+)\.(\d+)\.(\d+)'))[1] AS INTEGER)) STORED, @@ -92,9 +85,14 @@ 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 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 ( diff --git a/src/git.js b/src/git.js index 00d3a007..c7375798 100644 --- a/src/git.js +++ b/src/git.js @@ -229,7 +229,8 @@ async function createPackage(repo, user) { // Currently there is no purpose to store the type of repo. But for the time being, // we will assume this could be used in the future as a way to determine how to interact with a repo. - newPack.repository = selectPackageRepository(pack.repository); + const packRepo = selectPackageRepository(pack.repository); + newPack.repository = packRepo; // Now during migration packages will have a 'versions' key, but otherwise the standard // package will just have a 'version'. @@ -260,7 +261,12 @@ async function createPackage(repo, user) { } // They match tag and version, stuff the data into the package. - const versionMetadata = await metadataAppendTarballInfo(pack, tag, user); + // Copy pack so we avoid to append tarball info to the same object + const versionMetadata = await metadataAppendTarballInfo( + structuredClone(pack), + tag, + user + ); // TODO:: // Its worthy to note that the function above assigns the current package.json file within the repo // as the version tag. Now this in most cases during a publish should be fine. @@ -276,7 +282,12 @@ async function createPackage(repo, user) { continue; } - newPack.versions[ver] = versionMetadata; + newPack.versions[ver] = { + name: packName, + repository: packRepo, + readme: readme, + metadata: versionMetadata, + }; versionCount++; // Check latest version. diff --git a/src/handlers/package_handler.js b/src/handlers/package_handler.js index b82d5c5b..aa6ee340 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; } @@ -269,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( @@ -420,9 +421,19 @@ async function deletePackagesName(req, res) { return; } + const packMetadata = packageExists.content?.versions[0]?.meta; + + if (packMetadata === null) { + await common.handleError(req, res, { + ok: false, + short: "Not Found", + content: `Cannot retrieve metadata for ${params.packageName} package`, + }); + } + const gitowner = await git.ownership( user.content, - utils.getOwnerRepoFromPackage(packageExists.content.data) + utils.getOwnerRepoFromPackage(packMetadata), ); if (!gitowner.ok) { @@ -619,14 +630,24 @@ 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; } + const meta = packExists.content?.versions[0]?.meta; + + if (meta === null) { + await common.handleError(req, res, { + ok: false, + short: "Not Found", + content: `Cannot retrieve metadata for ${params.packageName} package`, + }); + } + // Get `owner/repo` string format from package. - let ownerRepo = utils.getOwnerRepoFromPackage(packExists.content.data); + let ownerRepo = utils.getOwnerRepoFromPackage(meta); // Now it's important to note, that getPackageJSON was intended to be an internal function. // As such does not return a Server Status Object. This may change later, but for now, @@ -682,7 +703,7 @@ async function postPackagesVersion(req, res) { metadata: packMetadata, }; - const newName = packMetadata.name; + const newName = packageData.name; const currentName = packExists.content.name; if (newName !== currentName && !params.rename) { logger.generic( @@ -725,7 +746,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, @@ -735,12 +756,25 @@ 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. const addVer = await database.insertNewPackageVersion( - packMetadata, packageData, rename ? currentName : null ); @@ -851,7 +885,7 @@ async function getPackagesVersionTarball(req, res) { // the download to take place from their servers. // But right before, lets do a couple simple checks to make sure we are sending to a legit site. - const tarballURL = pack.content.meta.tarball_url ?? ""; + const tarballURL = pack.content.meta?.tarball_url ?? ""; let hostname = ""; // Try to extract the hostname @@ -937,9 +971,19 @@ async function deletePackageVersion(req, res) { return; } + const packMetadata = packageExists.content?.versions[0]?.meta; + + if (packMetadata === null) { + await common.handleError(req, res, { + ok: false, + short: "Not Found", + content: `Cannot retrieve metadata for ${params.packageName} package`, + }); + } + const gitowner = await git.ownership( user.content, - utils.getOwnerRepoFromPackage(packageExists.content.data) + utils.getOwnerRepoFromPackage(packMetadata) ); if (!gitowner.ok) { 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/database.test.js b/src/tests_integration/database.test.js index 607cbf53..4c2df4bd 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(pack.createPack.name); + 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 ); @@ -113,19 +117,10 @@ describe("Package Lifecycle Tests", () => { expect(getAfterPublish.content.downloads).toEqual("0"); // Original stargazers already added to stargazers count expect(getAfterPublish.content.stargazers_count).toEqual("0"); - expect(getAfterPublish.content.data.name).toEqual(pack.createPack.name); - expect(getAfterPublish.content.data.readme).toEqual(pack.createPack.readme); - expect(getAfterPublish.content.data.repository).toEqual( - pack.createPack.repository - ); - expect(getAfterPublish.content.data.metadata).toEqual( - pack.createPack.metadata - ); expect(getAfterPublish.content.versions.length).toEqual(1); // Only 1 ver was provided expect(getAfterPublish.content.versions[0].semver).toEqual( pack.createPack.metadata.version ); - expect(getAfterPublish.content.versions[0].status).toEqual("latest"); expect(getAfterPublish.content.versions[0].license).toEqual("NONE"); expect(getAfterPublish.content.versions[0].package).toBeDefined(); @@ -134,7 +129,7 @@ describe("Package Lifecycle Tests", () => { expect(dupPublish.ok).toBeFalsy(); // === Let's rename our package - const NEW_NAME = "package-a-lifetime-rename"; + const NEW_NAME = `${pack.createPack.name}-rename`; const newName = await database.insertNewPackageName( NEW_NAME, pack.createPack.name @@ -197,7 +192,7 @@ describe("Package Lifecycle Tests", () => { ); expect(removeOnlyVersion.ok).toBeFalsy(); expect(removeOnlyVersion.content).toEqual( - `It's not possible to leave the ${NEW_NAME} without at least one published version` + `${NEW_NAME} package has less than 2 published versions: deletion not allowed.` ); // === Now let's add a version @@ -221,7 +216,6 @@ describe("Package Lifecycle Tests", () => { expect(getAfterVer.ok).toBeTruthy(); expect(getAfterVer.content.versions.length).toEqual(2); expect(getAfterVer.content.versions[0].semver).toEqual(v1_0_1.version); - expect(getAfterVer.content.versions[0].status).toEqual("latest"); expect(getAfterVer.content.versions[0].license).toEqual(v1_0_1.license); expect(getAfterVer.content.versions[0].meta.name).toEqual(v1_0_1.name); expect(getAfterVer.content.versions[0].meta.version).toEqual( @@ -238,7 +232,7 @@ describe("Package Lifecycle Tests", () => { ); expect(dupVer.ok).toBeFalsy(); expect(dupVer.content).toEqual( - "Cannot publish a new version with semver lower or equal than the current latest one." + `Not allowed to publish a version already present for ${pack.createPack.name}` ); // === Can we get this specific version with the new name @@ -247,7 +241,6 @@ describe("Package Lifecycle Tests", () => { v1_0_1.version ); expect(getNewVerOnly.ok).toBeTruthy(); - expect(getNewVerOnly.content.status).toEqual("latest"); expect(getNewVerOnly.content.semver).toEqual(v1_0_1.version); expect(getNewVerOnly.content.meta.name).toEqual(pack.createPack.name); @@ -257,22 +250,11 @@ describe("Package Lifecycle Tests", () => { pack.createPack.metadata.version ); expect(getOldVerOnly.ok).toBeTruthy(); - expect(getOldVerOnly.content.status).toEqual("published"); expect(getOldVerOnly.content.semver).toEqual( pack.createPack.metadata.version ); 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 @@ -305,16 +287,26 @@ describe("Package Lifecycle Tests", () => { expect(downPackOld.content.name).toEqual(NEW_NAME); expect(downPackOld.content.downloads).toEqual("1"); + // === Can we remove a non-existing version? + const noPubVer = "3.3.3"; + const removeNonExistingVersion = await database.removePackageVersion( + NEW_NAME, + noPubVer + ); + expect(removeNonExistingVersion.ok).toBeFalsy(); + expect(removeNonExistingVersion.content).toEqual( + `Unable to remove ${noPubVer} version of ${NEW_NAME} package.` + ); + // === 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.` + `Successfully removed ${v1_0_1.version} version of ${NEW_NAME} package.` ); // === Is our old version the latest again? @@ -325,7 +317,6 @@ describe("Package Lifecycle Tests", () => { expect(newLatestVer.content.versions[0].semver).toEqual( pack.createPack.metadata.version ); - expect(newLatestVer.content.versions[0].status).toEqual("latest"); // === Can we reinsert a previous deleted version? // This is intentionally unsupported because we want a new package to be always @@ -337,7 +328,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? @@ -362,17 +353,7 @@ describe("Package Lifecycle Tests", () => { `Successfully removed ${pack.createPack.metadata.version} version of ${NEW_NAME} package.` ); - // === Can we remove a non-existing version? - const removeNonExistingVersion = await database.removePackageVersion( - NEW_NAME, - pack.createPack.metadata.version - ); - expect(removeNonExistingVersion.ok).toBeFalsy(); - expect(removeNonExistingVersion.content).toEqual( - `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, @@ -405,6 +386,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(pack.createPack.name); + expect(deletedNameAvailable.ok).toBeFalsy(); }); test("User A Lifecycle Test", async () => { const user = require("./fixtures/lifetime/user-a.js"); @@ -530,7 +515,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(); 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" } +}); diff --git a/src/utils.js b/src/utils.js index 95d9b3ce..d9e19115 100644 --- a/src/utils.js +++ b/src/utils.js @@ -61,23 +61,16 @@ async function constructPackageObjectFull(pack) { return retVer; }; - const findLatestVersion = (vers) => { - for (const v of vers) { - if (v.status === "latest") { - return v.semver; - } - } - return null; - }; - - let newPack = pack.data; + // We need to copy the metadata of the latest version in order to avoid an + // auto-reference in the versions array that leads to a freeze in JSON stringify stage. + let newPack = structuredClone(pack?.versions[0]?.meta ?? {}); newPack.name = pack.name; newPack.downloads = pack.downloads; newPack.stargazers_count = pack.stargazers_count; newPack.versions = parseVersions(pack.versions); - newPack.releases = { - latest: findLatestVersion(pack.versions), - }; + // database.getPackageByName() sorts the JSON array versions in descending order, + // so no need to find the latest semver, it's the first one (index 0). + newPack.releases = { latest: pack?.versions[0]?.semver ?? "" }; logger.generic(6, "Built Package Object Full without Error");