diff --git a/.codacy.yaml b/.codacy.yaml index 23259c5d..0a440619 100644 --- a/.codacy.yaml +++ b/.codacy.yaml @@ -4,4 +4,4 @@ engines: minTokenMatch: 80 exclude_paths: - "./docs/*" - - "./scripts/**" + - "./scripts/**/**" diff --git a/.compactignore b/.compactignore index 062dbc8b..048a2ad6 100644 --- a/.compactignore +++ b/.compactignore @@ -14,6 +14,8 @@ coverage/ > gcloudignore .gcloudignore test/ +tests/ +scripts/ docs/reference docs/resources/ docs/build.md diff --git a/.gcloudignore b/.gcloudignore index f2ef0b89..1fedd7fd 100644 --- a/.gcloudignore +++ b/.gcloudignore @@ -5,13 +5,13 @@ node_modules/ data/ .gcloudignore test/ +tests/ +scripts/ docs/reference docs/resources/ docs/build.md docs/host_your_own.md docs/writingIntegrationTests.md -scripts/ -health-check-output.json # Just in case it's output and not deleted # Git Tooling / NPM Tooling .git/ .github/ diff --git a/codeql-config.yml b/codeql-config.yml index deb8c738..dfec5e44 100644 --- a/codeql-config.yml +++ b/codeql-config.yml @@ -6,4 +6,5 @@ queries: paths-ignore: - ./test + - ./tests # https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors diff --git a/docs/reference/API_Definition.md b/docs/reference/API_Definition.md index eb784f12..0014589b 100644 --- a/docs/reference/API_Definition.md +++ b/docs/reference/API_Definition.md @@ -1,3 +1,5 @@ +# WARNING This file is deprecated. And will soon be removed. Leaving our swagger instance to be the complete standard in the Pulsar backend API documentation. + # **[GET]** / A non-essential endpoint, returning a status message, and the server version. @@ -25,7 +27,7 @@ Parameters: --- -* page _(optional)_ `[integer]` | Location: `query` | Defaults: `1` +* page _(optional)_ `[integer]` | Location: `query` | Defaults: `1` - Indicate the page number to return. diff --git a/docs/resources/refactor-docs.md b/docs/resources/refactor-docs.md new file mode 100644 index 00000000..4f07769d --- /dev/null +++ b/docs/resources/refactor-docs.md @@ -0,0 +1,80 @@ +# DON'T KEEP THIS FILE + +Alright, so lets do a big time refactor, because it's fun, and I get bothered looking at the same code for too long or something. + +Essentially here's the new idea: + +## HTTP Handling + +Stop treating it as if it was special. HTTP handling is essentially only a utility function that's easily **replicable** and should be treated as such. + +The only part of an HTTP handling process that matters is the logic that's preformed. The logic of returning the data depending on states of SSO's or adding pagination or even erroring out is insanely easily replicable. + +So we should abstract away from hardcoding endless functions for HTTP handling as much as possible. So here's my idea: + +Every endpoint is it's own tiny module. This module should export at least two things: + +* `logic()` This function will be called to handle the actual logic of the endpoint, passing all relevant data to it +* `params()` This function will return a parameter object consisting of all query parameters that this endpoint expects to receive +* `endpoint` The endpoint object will then provide the endpoint logic with everything else it needs to define any given endpoint. + +From here the `main.js` module should instead import all modules needed, and iterate through them to create every single endpoint as needed. This may result in a slightly longer startup time, but overall I hope the increase in code readability and less duplication will be worth it. + +So this means that every module is imported, the `endpoint` object is read to setup the endpoint, and from there, it's made available as an endpoint via express, which can then, once hit, use the `params()` function to prepare the query parameters, and then pass those off to the `logic()` function. + +### `endpoint` Structure + +The `path` here is an array since in some instances, we want to accept multiple paths, such as `POST /api/packages` and `POST /api/themes`. + +```javascript +const endpoint = { + // Can be "GET", "POST", "DELETE" + method: "GET", + paths: [ "/api/themes" ], + // Can be "generic" or "auth" + rate_limit: "generic", + options: { + // This would be the headers to return for `HTTP OPTIONS` req: + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } +}; +``` + +## Returning HTTP handling + +Again, the logic here is easily replicable. So we shouldn't make it special. And if finally doing a proper rewrite, we can incorporate a proper SSO, and have a different type for every single return. This way the actual handling of any return, can instead be a factor of a `httpReturn()` function of the SSO itself, rather than baked in logic. So that way we can keep the return logic as unique as needed, as the uniqueness depends solely on the uniqueness of the SSO being returned. + +## Tests + +(As always the bane of existence) + +With this refactor, we no longer need true "integration" tests. As integration can be tested on if the proper endpoints being hit call the proper endpoint.logic() function. Beyond that the majority of "integration" testing would be relegated to interactions with external services working as expected. + +Meaning the only tests we would likely need are: + +* `tests` This would be the vast majority of tests, able to be generic, and not needing any fancy setup +* `database` This suite of tests should purely test if DB calls do what we expect +* `integration` A small suite of full integration tests is never a bad idea. To test that API calls have the intended effects on the DB. With a huge focus on having the intended effects. As we are seeing some examples where the expected data is not appearing or being applied to the DB as we want. +* `external` We don't do this currently. But a suite of external tests that are run on a maybe monthly basis is not a bad idea. This could allow us to ensure external APIs are returning data as expected. + +--- + +I think this is plenty to focus on now. At the very least the changes described here would likely mean a rewrite of about or over half the entire codebase. But if all goes to plan, would mean that every single piece of logic is more modular, keeping logic related to itself within the same file, and if tests are effected as hoped, would mean a much more robust testing solution, that who knows, may actually be able to achieve near 100% testing coverage. + +One side effect of all this change, means the possibility of generating documentation of the API based totally on the documentation itself, where we no longer would be reliant on my own `@confused-techie/quick-webserver-docs` module, nor having to ensure comments are updated. + +## Documentation + +Alright, so some cool ideas about documentation. + +Within `./src/parameters` we have modules named after the common name of any given parameter. + +Then to ensure our OpenAPI docs are ALWAYS up to date with the actual code, we take the `params` object of every single endpoint module, and we actually iterate through the `params` there and match those against these modules within `parameters`. + +So that the parameters within our docs will always match exactly with what's actually used. This does mean the name we use for the parameter within code has to match up with the names of the files. + +Additionally, for the content, we can reference a fileName from ./tests/models so that we can accomplish the following: + +* Use that object's `schema` and `example` to generate an object +* We can also possibly use this to ensure the return matches what we expect. diff --git a/jest.config.js b/jest.config.js index f5cb0f17..63514535 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,53 +1,43 @@ const config = { - setupFilesAfterEnv: ["/test/global.setup.jest.js"], + setupFilesAfterEnv: [ + "/tests/helpers/global.setup.jest.js" + ], verbose: true, collectCoverage: true, - coverageReporters: ["text", "clover"], + coverageReporters: [ + "text", + "clover" + ], coveragePathIgnorePatterns: [ - "/src/tests_integration/fixtures/**", - "/test/fixtures/**", - "/node_modules/**", + "/tests/", + "/test/", + "/node_modules/" ], projects: [ { displayName: "Integration-Tests", globalSetup: "/node_modules/@databases/pg-test/jest/globalSetup", - globalTeardown: - "/node_modules/@databases/pg-test/jest/globalTeardown", + globalTeardown: "/node_modules/@databases/pg-test/jest/globalTeardown", setupFilesAfterEnv: [ - "/test/handlers.setup.jest.js", - "/test/global.setup.jest.js", + "/tests/helpers/handlers.setup.jest.js", + "/tests/helpers/global.setup.jest.js" ], testMatch: [ - "/test/*.integration.test.js", - "/test/database/**/**.js", - ], + "/tests/database/**.test.js", + "/tests/http/**.test.js", + "/tests/vcs/**.test.js" + ] }, { displayName: "Unit-Tests", - setupFilesAfterEnv: ["/test/global.setup.jest.js"], - testMatch: [ - "/test/*.unit.test.js", - "/test/handlers/**/**.test.js", - ], - }, - { - displayName: "VCS-Tests", - setupFilesAfterEnv: ["/test/global.setup.jest.js"], - testMatch: ["/test/*.vcs.test.js"], - }, - { - displayName: "Handler-Tests", - globalSetup: "/node_modules/@databases/pg-test/jest/globalSetup", - globalTeardown: - "/node_modules/@databases/pg-test/jest/globalTeardown", setupFilesAfterEnv: [ - "/test/handlers.setup.jest.js", - "/test/global.setup.jest.js", + "/tests/helpers/global.setup.jest.js" ], - testMatch: ["/test/*.handler.integration.test.js"], - }, - ], + testMatch: [ + "/tests/unit/**/**.test.js" + ] + } + ] }; module.exports = config; diff --git a/package.json b/package.json index 2098d42e..c9e27c76 100644 --- a/package.json +++ b/package.json @@ -8,18 +8,14 @@ }, "scripts": { "start": "node ./src/server.js", + "test": "cross-env NODE_ENV=test PULSAR_STATUS=dev jest --selectProjects Integration-Tests Unit-Tests --runInBand", "test:unit": "cross-env NODE_ENV=test PULSAR_STATUS=dev jest --selectProjects Unit-Tests", "test:integration": "cross-env NODE_ENV=test PULSAR_STATUS=dev jest --selectProjects Integration-Tests", "start:dev": "cross-env PULSAR_STATUS=dev node ./src/dev_server.js", - "test": "cross-env NODE_ENV=test PULSAR_STATUS=dev jest --selectProjects Integration-Tests Unit-Tests VCS-Tests", - "test:vcs": "cross-env NODE_ENV=test PULSAR_STATUS=dev MOCK_GH=false jest --selectProjects VCS-Tests", - "test:handlers": "cross-env NODE_ENV=test PULSAR_STATUS=dev jest --selectProjects Handler-Tests", - "api-docs": "quick-webserver-docs -i ./src/main.js -o ./docs/reference/API_Definition.md", "lint": "prettier --check -u -w .", "complex": "cr --newmi --config .complexrc .", "js-docs": "jsdoc2md -c ./jsdoc.conf.js ./src/*.js ./src/handlers/*.js ./docs/resources/jsdoc_typedef.js > ./docs/reference/Source_Documentation.md", "contributors:add": "all-contributors add", - "test_search": "node ./scripts/tools/search.js", "migrations": "pg-migrations apply --directory ./scripts/migrations", "ignore": "compactignore", "tool:delete": "node ./scripts/tools/manual-delete-package.js", diff --git a/scripts/migrations/0002-post-star-test.sql b/scripts/deprecated-migrations/0002-post-star-test.sql similarity index 100% rename from scripts/migrations/0002-post-star-test.sql rename to scripts/deprecated-migrations/0002-post-star-test.sql diff --git a/scripts/migrations/0003-post-package-version-test.sql b/scripts/deprecated-migrations/0003-post-package-version-test.sql similarity index 100% rename from scripts/migrations/0003-post-package-version-test.sql rename to scripts/deprecated-migrations/0003-post-package-version-test.sql diff --git a/scripts/migrations/0004-delete-package-test.sql b/scripts/deprecated-migrations/0004-delete-package-test.sql similarity index 100% rename from scripts/migrations/0004-delete-package-test.sql rename to scripts/deprecated-migrations/0004-delete-package-test.sql diff --git a/scripts/migrations/0005-get-user-test.sql b/scripts/deprecated-migrations/0005-get-user-test.sql similarity index 100% rename from scripts/migrations/0005-get-user-test.sql rename to scripts/deprecated-migrations/0005-get-user-test.sql diff --git a/scripts/migrations/0006-get-stars-test.sql b/scripts/deprecated-migrations/0006-get-stars-test.sql similarity index 100% rename from scripts/migrations/0006-get-stars-test.sql rename to scripts/deprecated-migrations/0006-get-stars-test.sql diff --git a/scripts/migrations/0007-get-package.sql b/scripts/deprecated-migrations/0007-get-package.sql similarity index 100% rename from scripts/migrations/0007-get-package.sql rename to scripts/deprecated-migrations/0007-get-package.sql diff --git a/scripts/deprecated-migrations/0008-migrated-initial.sql b/scripts/deprecated-migrations/0008-migrated-initial.sql new file mode 100644 index 00000000..9a320c6d --- /dev/null +++ b/scripts/deprecated-migrations/0008-migrated-initial.sql @@ -0,0 +1,120 @@ +-- Enter our Test data into the Database. + +INSERT INTO packages (pointer, package_type, name, creation_method, downloads, stargazers_count, data, original_stargazers) +VALUES ( + 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'package', 'language-css', 'user made', 400004, 1, + '{"name": "language-css", "readme": "Cool readme", "metadata": {"bugs": {"url": "https://github.com/atom/language-css/issues"}, + "name": "language-css", "engines": {"atom": "*","node":"*"},"license":"MIT","version":"0.45.7","homepage":"http://atom.github.io/language-css", + "keywords":["tree-sitter"],"repository":{"url":"https://github.com/atom/language-css.git","type":"git"},"description":"CSS Support in Atom", + "dependencies":{"tree-sitter-css":"^0.19.0"},"devDependencies":{"coffeelint":"^1.10.1"}},"repository":{"url":"https://github.com/atom/langauge-css", + "type":"git"}}', 76 +), ( + 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'package', 'language-cpp', 'user made', 849156, 1, + '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}', 91 +), ( + 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 'package', 'hydrogen', 'Migrated from Atom.io', 2562844, 1, + '{"name": "hydrogen", "readme": "Hydrogen Readme", "metadata": { "main": "./dist/main", "name": "Hydrogen", + "author": "nteract contributors", "engines": {"atom": ">=1.28.0 <2.0.0"}, "license": "MIT", "version": "2.16.3"}}', 821 +), ( + 'aea26882-8459-4725-82ad-41bf7aa608c3', 'package', 'atom-clock', 'Migrated from Atom.io', 1090899, 1, + '{"name": "atom-clock", "readme": "Atom-clok!", "metadata": { "main": "./lib/atom-clock", "name": "atom-clock", + "author": { "url": "https://github.com/b3by", "name": "Antonio Bevilacqua", "email": "b3by.in.the3.sky@gmail.com"}}}', 528 +), ( + '1e19da12-322a-4b37-99ff-64f866cc0cfa', 'package', 'hey-pane', 'Migrated from Atom.io', 206804, 1, + '{"name": "hey-pane", "readme": "hey-pane!", "metadata": { "main": "./lib/hey-pane", "license": "MIT"}}', 176 +), ( + 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 'theme', 'atom-material-ui', 'Migrated from Atom.io', 2509605, 1, + '{"name": "atom-material-ui", "readme": "ATOM!"}', 1772 +), ( + '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 'theme', 'atom-material-syntax', 'Migrated from Atom.io', 1743927, 1, + '{"name": "atom-material-syntax"}', 1309 +), ( + '504cd079-a6a4-4435-aa06-daab631b1243', 'theme', 'atom-dark-material-ui', 'Migrated from Atom.io', 300, 1, + '{"name": "atom-dark-material-ui"}', 2 +); + +INSERT INTO names (name, pointer) +VALUES ( + 'language-css', 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b' +), ( + 'language-cpp', 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1' +), ( + 'hydrogen', 'ee87223f-65ab-4a1d-8f45-09fcf8e64423' +), ( + 'atom-clock', 'aea26882-8459-4725-82ad-41bf7aa608c3' +), ( + 'hey-pane', '1e19da12-322a-4b37-99ff-64f866cc0cfa' +), ( + 'atom-material-ui', 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc' +), ( + 'atom-material-syntax', '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac' +), ( + 'atom-dark-material-ui', '504cd079-a6a4-4435-aa06-daab631b1243' +); + +INSERT INTO versions (package, status, semver, license, engine, meta) +VALUES ( + 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'published', '0.45.7', 'MIT', '{"atom": "*", "node": "*"}', + '{"name": "language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"], + "tarball_url": "https://github.com/pulsar-edit/language-css"}' +), ( + 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'latest', '0.46.0', 'MIT', '{"atom": "*", "node": "*"}', + '{"name": "language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"]}' +), ( + 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'published', '0.45.0', 'MIT', '{"atom": "*", "node": "*"}', + '{"name":"language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"]}' +), ( + 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'published', '0.11.8', 'MIT', '{"atom": "*", "node": "*"}', + '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}' +), ( + 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'latest', '0.11.9', 'MIT', '{"atom": "*", "node": "*"}', + '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}' +), ( + 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 'latest', '2.16.3', 'MIT', '{"atom": "*"}', + '{"name": "Hydrogen", "dist": {"tarball": "https://www.atom.io/api/packages/hydrogen/version/2.16.3/tarball"}}' +), ( + 'aea26882-8459-4725-82ad-41bf7aa608c3', 'latest', '0.1.18', 'MIT', '{"atom": "*"}', + '{"name": "atom-clock", "dist": {"tarball": "https://www.atom.io/api/packages/atom-clock/version/1.18.0/tarball"}}' +), ( + '1e19da12-322a-4b37-99ff-64f866cc0cfa', 'latest', '1.2.0', 'MIT', '{"atom": "*"}', + '{"name":"hey-pane", "dist": {"tarball": "https://www.atom.io/api/packages/hydrogen/version/1.2.0/tarball"}}' +), ( + 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 'latest', '2.1.3', 'MIT', '{"atom": "*"}', + '{"name": "atom-material-ui", "dist": {"tarball": "https://www.atom.io/api/packages/atom-material-ui/version/2.1.3/tarball"}}' +), ( + '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 'latest', '1.0.8', 'MIT', '{"atom":"*"}', + '{"name": "atom-material-syntax", "dist": {"tarball":"https://www.atom/io/api/packages/atom-material-syntax/version/1.0.8/tarball"}}' +), ( + '504cd079-a6a4-4435-aa06-daab631b1243', 'latest', '1.0.0', 'MIT', '{"atom": "*"}', + '{"name":"atom-dark-material-ui", "dist": {"tarball": "https://www.atom.io/api/packages/atom-dark-material-ui/versions/1.0.0/tarball"}}' +); + +INSERT INTO users (username, node_id, avatar) +VALUES ( + 'dever', 'dever-nodeid', 'https://roadtonowhere.com' +), ( + 'no_perm_user', 'no-perm-user-nodeid', 'https://roadtonowhere.com' +), ( + 'admin_user', 'admin-user-nodeid', 'https://roadtonowhere.com' +), ( + 'has-no-stars', 'has-no-stars-nodeid', 'https://roadtonowhere.com' +), ( + 'has-all-stars', 'has-all-stars-nodeid', 'https://roadtonowhere.com' +); + +INSERT INTO stars (package, userid) +VALUES ( + 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 5 +), ( + 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 5 +), ( + 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 5 +), ( + 'aea26882-8459-4725-82ad-41bf7aa608c3', 5 +), ( + '1e19da12-322a-4b37-99ff-64f866cc0cfa', 5 +), ( + 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 5 +), ( + '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 5 +); diff --git a/src/debug_utils.js b/scripts/deprecated/debug_utils.js similarity index 100% rename from src/debug_utils.js rename to scripts/deprecated/debug_utils.js diff --git a/test/README.md b/scripts/deprecated/test/README.md similarity index 100% rename from test/README.md rename to scripts/deprecated/test/README.md diff --git a/test/delete.packages.handler.integration.test.js b/scripts/deprecated/test/delete.packages.handler.integration.test.js similarity index 100% rename from test/delete.packages.handler.integration.test.js rename to scripts/deprecated/test/delete.packages.handler.integration.test.js diff --git a/test/get.packages.handler.integration.test.js b/scripts/deprecated/test/get.packages.handler.integration.test.js similarity index 100% rename from test/get.packages.handler.integration.test.js rename to scripts/deprecated/test/get.packages.handler.integration.test.js diff --git a/test/handlers/common_handler/auth_fail.test.js b/scripts/deprecated/test/handlers/common_handler/auth_fail.test.js similarity index 100% rename from test/handlers/common_handler/auth_fail.test.js rename to scripts/deprecated/test/handlers/common_handler/auth_fail.test.js diff --git a/test/handlers/common_handler/bad_package_json.test.js b/scripts/deprecated/test/handlers/common_handler/bad_package_json.test.js similarity index 100% rename from test/handlers/common_handler/bad_package_json.test.js rename to scripts/deprecated/test/handlers/common_handler/bad_package_json.test.js diff --git a/test/handlers/common_handler/bad_repo_json.test.js b/scripts/deprecated/test/handlers/common_handler/bad_repo_json.test.js similarity index 100% rename from test/handlers/common_handler/bad_repo_json.test.js rename to scripts/deprecated/test/handlers/common_handler/bad_repo_json.test.js diff --git a/test/handlers/common_handler/express_fakes.js b/scripts/deprecated/test/handlers/common_handler/express_fakes.js similarity index 100% rename from test/handlers/common_handler/express_fakes.js rename to scripts/deprecated/test/handlers/common_handler/express_fakes.js diff --git a/test/handlers/common_handler/handle_detailed_error.test.js b/scripts/deprecated/test/handlers/common_handler/handle_detailed_error.test.js similarity index 100% rename from test/handlers/common_handler/handle_detailed_error.test.js rename to scripts/deprecated/test/handlers/common_handler/handle_detailed_error.test.js diff --git a/test/handlers/common_handler/missing_auth_json.test.js b/scripts/deprecated/test/handlers/common_handler/missing_auth_json.test.js similarity index 100% rename from test/handlers/common_handler/missing_auth_json.test.js rename to scripts/deprecated/test/handlers/common_handler/missing_auth_json.test.js diff --git a/test/handlers/common_handler/not_found.test.js b/scripts/deprecated/test/handlers/common_handler/not_found.test.js similarity index 100% rename from test/handlers/common_handler/not_found.test.js rename to scripts/deprecated/test/handlers/common_handler/not_found.test.js diff --git a/test/handlers/common_handler/not_supported.test.js b/scripts/deprecated/test/handlers/common_handler/not_supported.test.js similarity index 100% rename from test/handlers/common_handler/not_supported.test.js rename to scripts/deprecated/test/handlers/common_handler/not_supported.test.js diff --git a/test/handlers/common_handler/package_exists.test.js b/scripts/deprecated/test/handlers/common_handler/package_exists.test.js similarity index 100% rename from test/handlers/common_handler/package_exists.test.js rename to scripts/deprecated/test/handlers/common_handler/package_exists.test.js diff --git a/test/handlers/common_handler/server_error.test.js b/scripts/deprecated/test/handlers/common_handler/server_error.test.js similarity index 100% rename from test/handlers/common_handler/server_error.test.js rename to scripts/deprecated/test/handlers/common_handler/server_error.test.js diff --git a/test/handlers/common_handler/site_wide_not_found.test.js b/scripts/deprecated/test/handlers/common_handler/site_wide_not_found.test.js similarity index 100% rename from test/handlers/common_handler/site_wide_not_found.test.js rename to scripts/deprecated/test/handlers/common_handler/site_wide_not_found.test.js diff --git a/test/handlers/get_package_handler/getPackages.test.js b/scripts/deprecated/test/handlers/get_package_handler/getPackages.test.js similarity index 100% rename from test/handlers/get_package_handler/getPackages.test.js rename to scripts/deprecated/test/handlers/get_package_handler/getPackages.test.js diff --git a/test/handlers/get_package_handler/getPackagesSearch.test.js b/scripts/deprecated/test/handlers/get_package_handler/getPackagesSearch.test.js similarity index 100% rename from test/handlers/get_package_handler/getPackagesSearch.test.js rename to scripts/deprecated/test/handlers/get_package_handler/getPackagesSearch.test.js diff --git a/test/handlers/post_package_handler/postPackages.test.js b/scripts/deprecated/test/handlers/post_package_handler/postPackages.test.js similarity index 100% rename from test/handlers/post_package_handler/postPackages.test.js rename to scripts/deprecated/test/handlers/post_package_handler/postPackages.test.js diff --git a/test/oauth.handler.integration.test.js b/scripts/deprecated/test/oauth.handler.integration.test.js similarity index 100% rename from test/oauth.handler.integration.test.js rename to scripts/deprecated/test/oauth.handler.integration.test.js diff --git a/test/themes.handler.integration.test.js b/scripts/deprecated/test/themes.handler.integration.test.js similarity index 100% rename from test/themes.handler.integration.test.js rename to scripts/deprecated/test/themes.handler.integration.test.js diff --git a/test/users.handler.integration.test.js b/scripts/deprecated/test/users.handler.integration.test.js similarity index 100% rename from test/users.handler.integration.test.js rename to scripts/deprecated/test/users.handler.integration.test.js diff --git a/scripts/migrations/0001-initial-migration.sql b/scripts/migrations/0001-initial-migration.sql index 403d9e2f..5a81dc18 100644 --- a/scripts/migrations/0001-initial-migration.sql +++ b/scripts/migrations/0001-initial-migration.sql @@ -103,126 +103,3 @@ CREATE TABLE authstate ( keycode VARCHAR(256) NOT NULL UNIQUE, created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ); - ------------------------------------------------------------------------------- - --- Enter our Test data into the Database. - -INSERT INTO packages (pointer, package_type, name, creation_method, downloads, stargazers_count, data, original_stargazers) -VALUES ( - 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'package', 'language-css', 'user made', 400004, 1, - '{"name": "language-css", "readme": "Cool readme", "metadata": {"bugs": {"url": "https://github.com/atom/language-css/issues"}, - "name": "language-css", "engines": {"atom": "*","node":"*"},"license":"MIT","version":"0.45.7","homepage":"http://atom.github.io/language-css", - "keywords":["tree-sitter"],"repository":{"url":"https://github.com/atom/language-css.git","type":"git"},"description":"CSS Support in Atom", - "dependencies":{"tree-sitter-css":"^0.19.0"},"devDependencies":{"coffeelint":"^1.10.1"}},"repository":{"url":"https://github.com/atom/langauge-css", - "type":"git"}}', 76 -), ( - 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'package', 'language-cpp', 'user made', 849156, 1, - '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}', 91 -), ( - 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 'package', 'hydrogen', 'Migrated from Atom.io', 2562844, 1, - '{"name": "hydrogen", "readme": "Hydrogen Readme", "metadata": { "main": "./dist/main", "name": "Hydrogen", - "author": "nteract contributors", "engines": {"atom": ">=1.28.0 <2.0.0"}, "license": "MIT", "version": "2.16.3"}}', 821 -), ( - 'aea26882-8459-4725-82ad-41bf7aa608c3', 'package', 'atom-clock', 'Migrated from Atom.io', 1090899, 1, - '{"name": "atom-clock", "readme": "Atom-clok!", "metadata": { "main": "./lib/atom-clock", "name": "atom-clock", - "author": { "url": "https://github.com/b3by", "name": "Antonio Bevilacqua", "email": "b3by.in.the3.sky@gmail.com"}}}', 528 -), ( - '1e19da12-322a-4b37-99ff-64f866cc0cfa', 'package', 'hey-pane', 'Migrated from Atom.io', 206804, 1, - '{"name": "hey-pane", "readme": "hey-pane!", "metadata": { "main": "./lib/hey-pane", "license": "MIT"}}', 176 -), ( - 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 'theme', 'atom-material-ui', 'Migrated from Atom.io', 2509605, 1, - '{"name": "atom-material-ui", "readme": "ATOM!"}', 1772 -), ( - '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 'theme', 'atom-material-syntax', 'Migrated from Atom.io', 1743927, 1, - '{"name": "atom-material-syntax"}', 1309 -), ( - '504cd079-a6a4-4435-aa06-daab631b1243', 'theme', 'atom-dark-material-ui', 'Migrated from Atom.io', 300, 1, - '{"name": "atom-dark-material-ui"}', 2 -); - -INSERT INTO names (name, pointer) -VALUES ( - 'language-css', 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b' -), ( - 'language-cpp', 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1' -), ( - 'hydrogen', 'ee87223f-65ab-4a1d-8f45-09fcf8e64423' -), ( - 'atom-clock', 'aea26882-8459-4725-82ad-41bf7aa608c3' -), ( - 'hey-pane', '1e19da12-322a-4b37-99ff-64f866cc0cfa' -), ( - 'atom-material-ui', 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc' -), ( - 'atom-material-syntax', '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac' -), ( - 'atom-dark-material-ui', '504cd079-a6a4-4435-aa06-daab631b1243' -); - -INSERT INTO versions (package, status, semver, license, engine, meta) -VALUES ( - 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'published', '0.45.7', 'MIT', '{"atom": "*", "node": "*"}', - '{"name": "language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"], - "tarball_url": "https://github.com/pulsar-edit/language-css"}' -), ( - 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'latest', '0.46.0', 'MIT', '{"atom": "*", "node": "*"}', - '{"name": "language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"]}' -), ( - 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 'published', '0.45.0', 'MIT', '{"atom": "*", "node": "*"}', - '{"name":"language-css", "description": "CSS Support in Atom", "keywords": ["tree-sitter"]}' -), ( - 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'published', '0.11.8', 'MIT', '{"atom": "*", "node": "*"}', - '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}' -), ( - 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 'latest', '0.11.9', 'MIT', '{"atom": "*", "node": "*"}', - '{"name": "language-cpp", "description": "C++ Support in Atom", "keywords": ["tree-sitter"]}' -), ( - 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 'latest', '2.16.3', 'MIT', '{"atom": "*"}', - '{"name": "Hydrogen", "dist": {"tarball": "https://www.atom.io/api/packages/hydrogen/version/2.16.3/tarball"}}' -), ( - 'aea26882-8459-4725-82ad-41bf7aa608c3', 'latest', '0.1.18', 'MIT', '{"atom": "*"}', - '{"name": "atom-clock", "dist": {"tarball": "https://www.atom.io/api/packages/atom-clock/version/1.18.0/tarball"}}' -), ( - '1e19da12-322a-4b37-99ff-64f866cc0cfa', 'latest', '1.2.0', 'MIT', '{"atom": "*"}', - '{"name":"hey-pane", "dist": {"tarball": "https://www.atom.io/api/packages/hydrogen/version/1.2.0/tarball"}}' -), ( - 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 'latest', '2.1.3', 'MIT', '{"atom": "*"}', - '{"name": "atom-material-ui", "dist": {"tarball": "https://www.atom.io/api/packages/atom-material-ui/version/2.1.3/tarball"}}' -), ( - '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 'latest', '1.0.8', 'MIT', '{"atom":"*"}', - '{"name": "atom-material-syntax", "dist": {"tarball":"https://www.atom/io/api/packages/atom-material-syntax/version/1.0.8/tarball"}}' -), ( - '504cd079-a6a4-4435-aa06-daab631b1243', 'latest', '1.0.0', 'MIT', '{"atom": "*"}', - '{"name":"atom-dark-material-ui", "dist": {"tarball": "https://www.atom.io/api/packages/atom-dark-material-ui/versions/1.0.0/tarball"}}' -); - -INSERT INTO users (username, node_id, avatar) -VALUES ( - 'dever', 'dever-nodeid', 'https://roadtonowhere.com' -), ( - 'no_perm_user', 'no-perm-user-nodeid', 'https://roadtonowhere.com' -), ( - 'admin_user', 'admin-user-nodeid', 'https://roadtonowhere.com' -), ( - 'has-no-stars', 'has-no-stars-nodeid', 'https://roadtonowhere.com' -), ( - 'has-all-stars', 'has-all-stars-nodeid', 'https://roadtonowhere.com' -); - -INSERT INTO stars (package, userid) -VALUES ( - 'd28c7ce5-c9c4-4fb6-a499-a7c6dcec355b', 5 -), ( - 'd27dbd37-e58e-4e02-b804-9e3e6ae02fb1', 5 -), ( - 'ee87223f-65ab-4a1d-8f45-09fcf8e64423', 5 -), ( - 'aea26882-8459-4725-82ad-41bf7aa608c3', 5 -), ( - '1e19da12-322a-4b37-99ff-64f866cc0cfa', 5 -), ( - 'a0ef01cb-720e-4c0d-80c5-f0ed441f31fc', 5 -), ( - '28952de5-ddbf-41a8-8d87-5d7e9d7ad7ac', 5 -); diff --git a/scripts/parameters/auth.js b/scripts/parameters/auth.js new file mode 100644 index 00000000..21441c11 --- /dev/null +++ b/scripts/parameters/auth.js @@ -0,0 +1,10 @@ +module.exports = { + name: "auth", + in: "header", + schema: { + type: "string" + }, + required: true, + allowEmptyValue: false, + description: "Authorization Headers." +}; diff --git a/scripts/parameters/direction.js b/scripts/parameters/direction.js new file mode 100644 index 00000000..a2ae641e --- /dev/null +++ b/scripts/parameters/direction.js @@ -0,0 +1,15 @@ +module.exports = { + name: "direction", + in: "query", + schema: { + type: "string", + enum: [ + "desc", + "asc" + ], + default: "desc" + }, + example: "desc", + allowEmptyValue: true, + description: "Direction to list search results." +}; diff --git a/scripts/parameters/engine.js b/scripts/parameters/engine.js new file mode 100644 index 00000000..addc33f1 --- /dev/null +++ b/scripts/parameters/engine.js @@ -0,0 +1,10 @@ +module.exports = { + name: "engine", + in: "query", + schema: { + type: "string" + }, + example: "1.0.0", + allowEmptyValue: true, + description: "Only show packages compatible with this Pulsar version. Must be a valid Semver." +}; diff --git a/scripts/parameters/fileExtension.js b/scripts/parameters/fileExtension.js new file mode 100644 index 00000000..08fdecc5 --- /dev/null +++ b/scripts/parameters/fileExtension.js @@ -0,0 +1,10 @@ +module.exports = { + name: "fileExtension", + in: "query", + schema: { + type: "string" + }, + example: "coffee", + allowEmptyValue: true, + description: "File extension of which to only show compatible grammar package's of." +}; diff --git a/scripts/parameters/login.js b/scripts/parameters/login.js new file mode 100644 index 00000000..c2dde762 --- /dev/null +++ b/scripts/parameters/login.js @@ -0,0 +1,11 @@ +module.exports = { + name: "login", + in: "path", + schema: { + type: "string" + }, + required: true, + allowEmptyValue: false, + example: "confused-Techie", + description: "The User from the URL path." +}; diff --git a/scripts/parameters/packageName.js b/scripts/parameters/packageName.js new file mode 100644 index 00000000..c364d9e6 --- /dev/null +++ b/scripts/parameters/packageName.js @@ -0,0 +1,11 @@ +module.exports = { + name: "packageName", + in: "path", + schema: { + type: "string" + }, + required: true, + allowEmptyValue: false, + example: "autocomplete-powershell", + description: "The name of the package to return details for. Must be URL escaped." +}; diff --git a/scripts/parameters/page.js b/scripts/parameters/page.js new file mode 100644 index 00000000..86ae65c3 --- /dev/null +++ b/scripts/parameters/page.js @@ -0,0 +1,12 @@ +module.exports = { + name: "page", + in: "query", + schema: { + type: "number", + minimum: 1, + default: 1 + }, + example: 1, + allowEmptyValue: true, + description: "The page of available results to return." +}; diff --git a/scripts/parameters/query.js b/scripts/parameters/query.js new file mode 100644 index 00000000..ab80a63d --- /dev/null +++ b/scripts/parameters/query.js @@ -0,0 +1,10 @@ +module.exports = { + name: "q", + in: "query", + schema: { + type: "string" + }, + example: "generic-lsp", + required: true, + description: "Search query." +}; diff --git a/scripts/parameters/service.js b/scripts/parameters/service.js new file mode 100644 index 00000000..db5e8fdf --- /dev/null +++ b/scripts/parameters/service.js @@ -0,0 +1,10 @@ +module.exports = { + name: "service", + in: "query", + schema: { + type: "string" + }, + example: "autocomplete.watchEditor", + allowEmptyValue: true, + description: "The service of which to filter packages by." +}; diff --git a/scripts/parameters/serviceType.js b/scripts/parameters/serviceType.js new file mode 100644 index 00000000..2cf69aca --- /dev/null +++ b/scripts/parameters/serviceType.js @@ -0,0 +1,16 @@ +module.exports = { + name: "serviceType", + in: "query", + schema: { + type: "string", + enum: [ + "consumed", + "provided" + ] + }, + example: "consumed", + allowEmptyValue: true, + description: "Chooses whether to display 'consumer' or 'provider's of the specified 'service'." +}; +// TODO determine if there's a way to indicate this is a required field when +// using the 'service' query param. diff --git a/scripts/parameters/serviceVersion.js b/scripts/parameters/serviceVersion.js new file mode 100644 index 00000000..6b0f4fce --- /dev/null +++ b/scripts/parameters/serviceVersion.js @@ -0,0 +1,10 @@ +module.exports = { + name: "serviceVersion", + in: "query", + schema: { + type: "string" + }, + example: "0.0.1", + allowEmptyValue: true, + description: "Filter by a specific version of the 'service'." +}; diff --git a/scripts/parameters/sort.js b/scripts/parameters/sort.js new file mode 100644 index 00000000..8f5509e6 --- /dev/null +++ b/scripts/parameters/sort.js @@ -0,0 +1,18 @@ +module.exports = { + name: "sort", + in: "query", + schema: { + type: "string", + enum: [ + "downloads", + "created_at", + "updated_at", + "stars", + "relevance" + ], + default: "relevance" + }, + example: "downloads", + allowEmptyValue: true, + description: "Method to sort the results." +}; diff --git a/scripts/parameters/versionName.js b/scripts/parameters/versionName.js new file mode 100644 index 00000000..132ef28b --- /dev/null +++ b/scripts/parameters/versionName.js @@ -0,0 +1,11 @@ +module.exports = { + name: "versionName", + in: "path", + schema: { + type: "string" + }, + required: true, + allowEmptyValue: false, + example: "1.0.0", + description: "The version of the package to access." +}; diff --git a/src/auth.js b/src/auth.js index 0b5c73a8..afd7c691 100644 --- a/src/auth.js +++ b/src/auth.js @@ -23,7 +23,7 @@ async function verifyAuth(token, db) { return { ok: false, - short: "Bad Auth", + short: "unauthorized", content: "User Token not a valid format.", }; } @@ -47,7 +47,7 @@ async function verifyAuth(token, db) { case 401: // When the user provides bad authentication, lets tell them it's bad auth. logger.generic(6, "auth.verifyAuth() API Call Returning Bad Auth"); - return { ok: false, short: "Bad Auth", content: userData }; + return { ok: false, short: "unauthorized", content: userData }; break; default: logger.generic( @@ -56,7 +56,7 @@ async function verifyAuth(token, db) { { type: "object", obj: userData } ); - return { ok: false, short: "Server Error", content: userData }; + return { ok: false, short: "server_error", content: userData }; } } @@ -99,7 +99,7 @@ async function verifyAuth(token, db) { return { ok: false, - short: "Server Error", + short: "server_error", content: "An unexpected Error occured while verifying your user.", }; } diff --git a/src/config.js b/src/config.js index 45a94078..238c5cf2 100644 --- a/src/config.js +++ b/src/config.js @@ -59,42 +59,35 @@ function getConfig() { // But we will create a custom object here to return, with all values, and choosing between the env vars and config // Since if this is moved to Google App Engine, these variables will all be environment variables. So we will look for both. + const findValue = (key, def) => { + return process.env[key] ?? data.env_variables[key] ?? def ?? undefined; + }; + return { - port: process.env.PORT ?? data.env_variables.PORT, - server_url: process.env.SERVERURL ?? data.env_variables.SERVERURL, - paginated_amount: process.env.PAGINATE ?? data.env_variables.PAGINATE, + port: findValue("PORT", 8080), + server_url: findValue("SERVERURL"), + paginated_amount: findValue("PAGINATE", 30), prod: process.env.NODE_ENV === "production" ? true : false, - cache_time: process.env.CACHETIME ?? data.env_variables.CACHETIME, - GCLOUD_STORAGE_BUCKET: - process.env.GCLOUD_STORAGE_BUCKET ?? - data.env_variables.GCLOUD_STORAGE_BUCKET, - GOOGLE_APPLICATION_CREDENTIALS: - process.env.GOOGLE_APPLICATION_CREDENTIALS ?? - data.env_variables.GOOGLE_APPLICATION_CREDENTIALS, - GH_CLIENTID: process.env.GH_CLIENTID ?? data.env_variables.GH_CLIENTID, - GH_USERAGENT: process.env.GH_USERAGENT ?? data.env_variables.GH_USERAGENT, - GH_REDIRECTURI: - process.env.GH_REDIRECTURI ?? data.env_variables.GH_REDIRECTURI, - GH_CLIENTSECRET: - process.env.GH_CLIENTSECRET ?? data.env_variables.GH_CLIENTSECRET, - DB_HOST: process.env.DB_HOST ?? data.env_variables.DB_HOST, - DB_USER: process.env.DB_USER ?? data.env_variables.DB_USER, - DB_PASS: process.env.DB_PASS ?? data.env_variables.DB_PASS, - DB_DB: process.env.DB_DB ?? data.env_variables.DB_DB, - DB_PORT: process.env.DB_PORT ?? data.env_variables.DB_PORT, - DB_SSL_CERT: process.env.DB_SSL_CERT ?? data.env_variables.DB_SSL_CERT, - LOG_LEVEL: process.env.LOG_LEVEL ?? data.env_variables.LOG_LEVEL, - LOG_FORMAT: process.env.LOG_FORMAT ?? data.env_variables.LOG_FORMAT, - RATE_LIMIT_GENERIC: - process.env.RATE_LIMIT_GENERIC ?? data.env_variables.RATE_LIMIT_GENERIC, - RATE_LIMIT_AUTH: - process.env.RATE_LIMIT_AUTH ?? data.env_variables.RATE_LIMIT_AUTH, - WEBHOOK_PUBLISH: - process.env.WEBHOOK_PUBLISH ?? data.env_variables.WEBHOOK_PUBLISH, - WEBHOOK_VERSION: - process.env.WEBHOOK_VERSION ?? data.env_variables.WEBHOOK_VERSION, - WEBHOOK_USERNAME: - process.env.WEBHOOK_USERNAME ?? data.env_variables.WEBHOOK_USERNAME, + cache_time: findValue("CACHETIME"), + GCLOUD_STORAGE_BUCKET: findValue("GCLOUD_STORAGE_BUCKET"), + GOOGLE_APPLICATION_CREDENTIALS: findValue("GOOGLE_APPLICATION_CREDENTIALS"), + GH_CLIENTID: findValue("GH_CLIENTID"), + GH_CLIENTSECRET: findValue("GH_CLIENTSECRET"), + GH_USERAGENT: findValue("GH_USERAGENT"), // todo maybe default? + GH_REDIRECTURI: findValue("GH_REDIRECTURI"), + DB_HOST: findValue("DB_HOST"), + DB_USER: findValue("DB_USER"), + DB_PASS: findValue("DB_PASS"), + DB_DB: findValue("DB_DB"), + DB_PORT: findValue("DB_PORT"), + DB_SSL_CERT: findValue("DB_SSL_CERT"), + LOG_LEVEL: findValue("LOG_LEVEL", 6), + LOG_FORMAT: findValue("LOG_FORMAT", "stdout"), + RATE_LIMIT_GENERIC: findValue("RATE_LIMIT_GENERIC"), + RATE_LIMIT_AUTH: findValue("RATE_LIMIT_AUTH"), + WEBHOOK_PUBLISH: findValue("WEBHOOK_PUBLISH"), + WEBHOOK_VERSION: findValue("WEBHOOK_VERSION"), + WEBHOOK_USERNAME: findValue("WEBHOOK_USERNAME"), }; } diff --git a/src/context.js b/src/context.js new file mode 100644 index 00000000..d9501389 --- /dev/null +++ b/src/context.js @@ -0,0 +1,18 @@ +// The CONST Context - Enables access to all other modules within the system +// By passing this object to everywhere needed allows not only easy access +// but greater control in mocking these later on +module.exports = { + logger: require("./logger.js"), + database: require("./database.js"), + webhook: require("./webhook.js"), + server_version: require("../package.json").version, + query: require("./query.js"), + vcs: require("./vcs.js"), + config: require("./config.js").getConfig(), + utils: require("./utils.js"), + auth: require("./auth.js"), + sso: require("./models/sso.js"), + ssoPaginate: require("./models/ssoPaginate.js"), + ssoRedirect: require("./models/ssoRedirect.js"), + ssoHTML: require("./models/ssoHTML.js") +}; diff --git a/src/controllers/deletePackagesPackageName.js b/src/controllers/deletePackagesPackageName.js new file mode 100644 index 00000000..c5e96c7e --- /dev/null +++ b/src/controllers/deletePackagesPackageName.js @@ -0,0 +1,85 @@ +/** + * @module deletePackagesPackageName + */ + +module.exports = { + docs: { + summary: "Delete a package.", + responses: { + 204: { + description: "An empty response, indicating success." + } + } + }, + endpoint: { + method: "DELETE", + paths: [ + "/api/packages/:packageName", + "/api/themes/:packageName" + ], + rateLimit: "auth", + successStatus: 204, + options: { + Allow: "DELETE, GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); }, + packageName: (context, req) => { return context.query.packageName(req); } + }, + + async logic(params, context) { + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addMessage("Please update your token if you haven't done so recently.") + .addCalls("auth.verifyAuth", user); + } + + // Lets also first check to make sure the package exists + const packageExists = await context.database.getPackageByName(params.packageName, true); + + if (!packageExists.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packageExists) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists); + } + + // Get `owner/repo` string format from pacakge + const ownerRepo = context.utils.getOwnerRepoFromPackage(packageExists.content.data); + + const gitowner = await context.vcs.ownership(user.content, ownerRepo); + + if (!gitowner.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(gitowner) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists) + .addCalls("vcs.ownership", gitowner); + } + + // Now they are logged in locally, and have permissions over the GitHub repo + const rm = await context.database.removePackageByName(params.packageName); + + if (!rm.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(rm) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists) + .addCalls("vcs.ownership", gitowner) + .addCalls("db.removePackageByName", rm); + } + + const sso = new context.sso(); + + return sso.isOk().addContent(false); + } +}; diff --git a/src/controllers/deletePackagesPackageNameStar.js b/src/controllers/deletePackagesPackageNameStar.js new file mode 100644 index 00000000..55b55e94 --- /dev/null +++ b/src/controllers/deletePackagesPackageNameStar.js @@ -0,0 +1,55 @@ +/** + * @module DeletePackagesPackageNameStar + */ + +module.exports = { + docs: { + summary: "Unstar a package.", + responses: { + 204: { + description: "An empty response, indicating success." + } + } + }, + endpoint: { + method: "DELETE", + paths: [ + "/api/packages/:packageName/star", + "/api/themes/:packageName/star" + ], + rateLimit: "auth", + successStatus: 204, + options: { + Allow: "DELETE, POST", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); }, + packageName: (context, req) => { return context.query.packageName(req); } + }, + async logic(params, context) { + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("auth.verifyAuth", user); + } + + const unstar = await context.database.updateDecrementStar(user.content, params.packageName); + + if (!unstar.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(unstar) + .addCalls("auth.verifyAuth", user) + .addCalls("db.updateDecrementStar", unstar); + } + + const sso = new context.sso(); + + return sso.isOk().addContent(false); + } +}; diff --git a/src/controllers/deletePackagesPackageNameVersionsVersionName.js b/src/controllers/deletePackagesPackageNameVersionsVersionName.js new file mode 100644 index 00000000..f6205855 --- /dev/null +++ b/src/controllers/deletePackagesPackageNameVersionsVersionName.js @@ -0,0 +1,90 @@ +/** + * @module deletePackagesPackageNameVersionsVersionName + */ + +module.exports = { + docs: { + summary: "Deletes a package version. Once a version is deleted, it cannot be used again." + }, + endpoint: { + method: "DELETE", + paths: [ + "/api/packages/:packageName/versions/:versionName", + "/api/themes/:packageName/versions/:versionName" + ], + rateLimit: "auth", + successStatus: 204, + options: { + Allow: "GET, DELETE", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); }, + packageName: (context, req) => { return context.query.packageName(req); }, + versionName: (context, req) => { return context.query.engine(req.params.versionName); } + }, + + async logic(params, context) { + // Moving this forward to do the least computationally expensive task first. + // Check version validity + if (params.versionName === false) { + const sso = new context.sso(); + + return sso.notOk().addShort("not_found") + .addMessage("The version provided is invalid."); + } + + // Verify the user has local and remote permissions + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("auth.verifyAuth", user); + } + + // Lets also first check to make sure the package exists + const packageExists = await context.database.getPackageByName(params.packageName, true); + + if (!packageExists.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packageExists) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists); + } + + const gitowner = await context.vcs.ownership(user.content, packageExists.content); + + if (!gitowner.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(gitowner) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists) + .addCalls("vcs.ownership", gitowner); + } + + // Mark the specified version for deletion, if version is valid + const removeVersion = await context.database.removePackageVersion( + params.packageName, + params.versionName + ); + + if (!removeVersion.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(removeVersion) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packageExists) + .addCalls("vcs.ownership", gitowner) + .addCalls("db.removePackageVersion", removeVersion); + } + + const sso = new context.sso(); + + return sso.isOk().addContent(false); + } +}; diff --git a/src/controllers/endpoints.js b/src/controllers/endpoints.js new file mode 100644 index 00000000..78630d6a --- /dev/null +++ b/src/controllers/endpoints.js @@ -0,0 +1,35 @@ +// Exports all the endpoints that need to be required + +// We register endpoints in a specific order because the loosy paths are always +// matched first. So we ensured to insert paths from strictest to loosest +// based on each parent slug or http method utilized. +// In simple terms, when a path has a parameter, add them longest path to shortest +module.exports = [ + require("./getLogin.js"), + require("./getOauth.js"), + require("./getPackages"), + require("./getPackagesFeatured.js"), + require("./getPackagesSearch.js"), + require("./getPat.js"), + require("./getRoot.js"), + require("./getStars.js"), + require("./getThemes.js"), + require("./getThemesFeatured.js"), + require("./getThemesSearch.js"), + require("./getUpdates.js"), + require("./getUsers.js"), + require("./postPackages.js"), + // Items with path parameters + require("./deletePackagesPackageNameVersionsVersionName.js"), + require("./deletePackagesPackageNameStar.js"), + require("./deletePackagesPackageName.js"), + require("./getPackagesPackageNameVersionsVersionNameTarball.js"), + require("./getPackagesPackageNameVersionsVersionName.js"), + require("./getPackagesPackageNameStargazers.js"), + require("./getPackagesPackageName.js"), + require("./postPackagesPackageNameVersionsVersionNameEventsUninstall.js"), + require("./postPackagesPackageNameVersions.js"), + require("./postPackagesPackageNameStar.js"), + require("./getUsersLoginStars.js"), + require("./getUsersLogin.js"), +]; diff --git a/src/controllers/getLogin.js b/src/controllers/getLogin.js new file mode 100644 index 00000000..34aa9e88 --- /dev/null +++ b/src/controllers/getLogin.js @@ -0,0 +1,48 @@ +/** + * @module getLogin + */ + +module.exports = { + docs: { + summary: "OAuth callback URL." + }, + endpoint: { + method: "GET", + paths: [ "/api/login" ], + rateLimit: "auth", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + }, + endpointKind: "raw" + }, + + async logic(req, res, context) { + // The first point of contact to log into the app. + // Since this will be the endpoint for a user to login, we need + // to redirect to GH. + // @see https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps + + // Generate a random key + const stateKey = context.utils.generateRandomString(64); + + // Before redirect, save the key into the db + const saveStateKey = await context.database.authStoreStateKey(stateKey); + + if (!saveStateKey.ok) { + res.status(500).json({ + message: "Application Error: Failed to generate secure state key." + }); + context.logger.httpLog(req, res); + return; + } + + res.status(302).redirect( + `https://github.com/login/oauth/authorize?client_id=${context.config.GH_CLIENTID}&redirect_uri=${context.config.GH_REDIRECTURI}&state=${stateKey}&scope=public_repo%20read:org` + ); + + context.logger.httpLog(req, res); + return; + } +}; diff --git a/src/controllers/getOauth.js b/src/controllers/getOauth.js new file mode 100644 index 00000000..249a8673 --- /dev/null +++ b/src/controllers/getOauth.js @@ -0,0 +1,128 @@ +/** + * @module getOauth + */ + +const superagent = require("superagent"); + +module.exports = { + docs: { + summary: "OAuth Callback URL." + }, + endpoint: { + method: "GET", + paths: [ "/api/oauth" ], + rateLimit: "auth", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + }, + endpointKind: "raw" + }, + + async logic(req, res, context) { + let params = { + state: req.query.state ?? "", + code: req.query.code ?? "" + }; + + // First we want to ensure that the received state key is valid + const validStateKey = await context.database.authCheckAndDeleteStateKey(params.state); + + if (!validStateKey.ok) { + res.status(500).json({ + message: "Application Error: Invalid State Key provided." + }); + context.logger.httpLog(req, res); + return; + } + + // Retrieve access token + const initialAuth = await superagent + .post("https://github.com/login/oauth/access_token") + .query({ + code: params.code, + redirect_uri: context.config.GH_REDIRECTURI, + client_id: context.config.GH_CLIENTID, + client_secret: context.config.GH_CLIENTSECRET + }); + + const accessToken = iniitalAuth.body?.access_token; + + if (accessToken === null || initialAuth.body?.token_type === null) { + res.status(500).json({ + message: "Application Error: Authentication to GitHub failed." + }); + context.logger.httpLog(req, res); + return; + } + + try { + // Request the user data using the access token + const userData = await superagent + .get("https://api.github.com/user") + .set({ Authorization: `Bearer ${accessToken}` }) + .set({ "User-Agent": context.config.GH_USERAGENT }); + + if (userData.status !== 200) { + res.status(500).json({ + message: `Application Error: Received HTTP Status ${userData.status}` + }); + context.logger.httpLog(req, res); + return; + } + + // Now retrieve the user data that we need to store into the DB + const username = userData.body.login; + const userId = userData.body.node_id; + const userAvatar = userData.body.avatar_url; + + const userExists = await context.database.getUserByNodeID(userId); + + if (userExists.ok) { + // This means that the user does in fact already exist. + // And from there they are likely reauthenticating, + // But since we don't save any type of auth tokens, the user just needs + // a new one and we should return their new one to them. + + // Now we redirect to the frontend site + res.redirect(`https://web.pulsar-edit.dev/users?token=${accessToken}`); + context.logger.httpLog(req, res); + return; + } + + // The user does not exist, so we save its data into the DB + let createdUser = await context.database.insertNewUser( + username, + userId, + userAvatar + ); + + if (!createdUser.ok) { + res.status(500).json({ + message: "Application Error: Creating the user account failed!" + }); + context.logger.httpLog(req, res); + return; + } + + // Before returning, lets append their access token + createdUser.content.token = accessToken; + + // Now re redirect to the frontend site + res.redirect( + `https://web.pulsar-edit.dev/users?token=${createdUser.content.token}` + ); + context.logger.httpLog(req, res); + return; + + } catch(err) { + context.logger.generic(2, "/api/oauth Caught an Error!", { type: "error", err: err }); + res.status(500).json({ + message: "Application Error: The server encountered an error processing the request." + }); + context.logger.httpLog(req, res); + return; + } + } +}; diff --git a/src/controllers/getPackages.js b/src/controllers/getPackages.js new file mode 100644 index 00000000..c1a8e713 --- /dev/null +++ b/src/controllers/getPackages.js @@ -0,0 +1,60 @@ +/** + * @module getPackages + */ + +module.exports = { + docs: { + summary: "List all packages" + }, + endpoint: { + method: "GET", + paths: [ "/api/packages" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "POST, GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + page: (context, req) => { return context.query.page(req); }, + sort: (context, req) => { return context.query.sort(req); }, + direction: (context, req) => { return context.query.dir(req); }, + serviceType: (context, req) => { return context.query.serviceType(req); }, + service: (context, req) => { return context.query.service(req); }, + serviceVersion: (context, req) => { return context.query.serviceVersion(req); }, + fileExtension: (context, req) => { return context.query.fileExtension(req); } + }, + + /** + * @async + * @memberof getPackages + * @function logic + * @desc Returns all packages to user, filtered by query params. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @returns {ssoPaginate} + */ + async logic(params, context) { + const packages = await context.database.getSortedPackages(params); + + if (!packages.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packages) + .addCalls("db.getSortedPackages", packages); + } + + const packObjShort = await context.utils.constructPackageObjectShort(packages.content); + + const packArray = Array.isArray(packObjShort) ? packObjShort : [ packObjShort ]; + + const ssoP = new context.ssoPaginate(); + + ssoP.total = packages.pagination.total; + ssoP.limit = packages.pagination.limit; + ssoP.buildLink(`${context.config.server_url}/api/packages`, packages.pagination.page, params); + + return ssoP.isOk().addContent(packArray); + } +}; diff --git a/src/controllers/getPackagesFeatured.js b/src/controllers/getPackagesFeatured.js new file mode 100644 index 00000000..7f7ec78b --- /dev/null +++ b/src/controllers/getPackagesFeatured.js @@ -0,0 +1,55 @@ +/** + * @module getPackagesFeatured + */ + +module.exports = { + docs: { + summary: "Returns all featured packages. Previously undocumented endpoint.", + responses: { + 200: { + description: "An array of features packages.", + content: { + "application/json": "$packageObjectShortArray" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/packages/featured" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: {}, + + /** + * @async + * @memberof getPackagesFeatured + * @function logic + * @desc Retreived a list of the featured packages, as Package Object Shorts. + */ + async logic(params, context) { + // TODO: Does not support engine query parameter as of now + const packs = await context.database.getFeaturedPackages(); + + if (!packs.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packs) + .addCalls("db.getFeaturedPackages", packs); + } + + const packObjShort = await context.utils.constructPackageObjectShort(packs.content); + + // The endpoint using this ufnction needs an array + const packArray = Array.isArray(packObjShort) ? packObjShort : [ packObjShort ]; + + const sso = new context.sso(); + + return sso.isOk().addContent(packArray); + } +}; diff --git a/src/controllers/getPackagesPackageName.js b/src/controllers/getPackagesPackageName.js new file mode 100644 index 00000000..60d88ba3 --- /dev/null +++ b/src/controllers/getPackagesPackageName.js @@ -0,0 +1,67 @@ +/** + * @module getPackagesPackageName + */ + +module.exports = { + docs: { + summary: "Show package details.", + responses: { + 200: { + description: "A 'Package Object Full' of the requested package.", + content: { + "application/json": "$packageObjectFull" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ + "/api/packages/:packageName", + "/api/themes/:packageName" + ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "DELETE, GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + engine: (context, req) => { return context.query.engine(req.query.engine); }, + packageName: (context, req) => { return context.query.packageName(req); } + }, + + /** + * @async + * @memberof getPackagesPackageName + * @function logic + * @desc Returns the data of a single requested package, as a Package Object Full. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @returns {sso} + */ + async logic(params, context) { + let pack = await context.database.getPackageByName(params.packageName, true); + + if (!pack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pack) + .addCalls("db.getPackageByName", pack); + } + + pack = await context.utils.constructPackageObjectFull(pack.content); + + if (params.engine !== false) { + // query.engine returns false if no valid query param is found. + // before using engineFilter we need to check the truthiness of it. + + pack = await context.utils.engineFilter(pack, params.engine); + } + + const sso = new context.sso(); + + return sso.isOk().addContent(pack); + } +}; diff --git a/src/controllers/getPackagesPackageNameStargazers.js b/src/controllers/getPackagesPackageNameStargazers.js new file mode 100644 index 00000000..cf4cd77f --- /dev/null +++ b/src/controllers/getPackagesPackageNameStargazers.js @@ -0,0 +1,71 @@ +/** + * @module getPackagesPackageNameStargazers + */ + +module.exports = { + docs: { + summary: "List the users that have starred a package." + }, + endpoint: { + method: "GET", + paths: [ + "/api/packages/:packageName/stargazers", + "/api/themes/:packageName/stargazers" + ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + packageName: (context, req) => { return context.query.packageName(req); } + }, + + /** + * @async + * @memberof getPackagesPackageNameStargazers + * @function logic + * @desc Returns an array of `star_gazers` from a specified package. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @returns {sso} + */ + async logic(params, context) { + // The following can't be executed in user mode because we need the pointer + const pack = await context.database.getPackageByName(params.packageName); + + if (!pack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pack) + .addCalls("db.getPackageByName", pack); + } + + const stars = await context.database.getStarringUsersByPointer(pack.content); + + if (!stars.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(stars) + .addCalls("db.getPackageByName", pack) + .addCalls("db.getStarringUsersByPointer", stars); + } + + const gazers = await context.database.getUserCollectionById(stars.content); + + if (!gazers.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(gazers) + .addCalls("db.getPackageByName", pack) + .addCalls("db.getStarringUsersByPointer", stars) + .addCalls("db.getUserCollectionById", gazers); + } + + const sso = new context.sso(); + + return sso.isOk().addContent(gazers.content); + } +}; diff --git a/src/controllers/getPackagesPackageNameVersionsVersionName.js b/src/controllers/getPackagesPackageNameVersionsVersionName.js new file mode 100644 index 00000000..97574d54 --- /dev/null +++ b/src/controllers/getPackagesPackageNameVersionsVersionName.js @@ -0,0 +1,65 @@ +/** + * @module getPackagesPackageNameVersionsVersionName + */ + +module.exports = { + docs: { + summary: "Get the details of a specific package version." + }, + endpoint: { + method: "GET", + paths: [ + "/api/packages/:packageName/versions/:versionName", + "/api/themes/:packageName/versions/:versionName" + ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET, DELETE", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + packageName: (context, req) => { return context.query.packageName(req); }, + versionName: (context, req) => { return context.query.engine(req.params.versionName); } + }, + + /** + * @async + * @memberof getPackagesPackageNameVersionsVersionName + * @function logic + * @desc Used to retreive the details of a specific version of a package. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @returns {sso} + */ + async logic(params, context) { + // Check the truthiness of the returned query engine + if (params.versionName === false) { + const sso = new context.sso(); + + return sso.notOk().addShort("not_found") + .addMessage("The version provided is invalid."); + } + + // Now we know the version is a valid semver. + + const pack = await context.database.getPackageVersionByNameAndVersion( + params.packageName, + params.versionName + ); + + if (!pack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pack) + .addCalls("db.getPackageVersionByNameAndVersion", pack); + } + + const packRes = await context.utils.constructPackageObjectJSON(pack.content); + + const sso = new context.sso(); + + return sso.isOk().addContent(packRes); + } +} diff --git a/src/controllers/getPackagesPackageNameVersionsVersionNameTarball.js b/src/controllers/getPackagesPackageNameVersionsVersionNameTarball.js new file mode 100644 index 00000000..f32a74b7 --- /dev/null +++ b/src/controllers/getPackagesPackageNameVersionsVersionNameTarball.js @@ -0,0 +1,123 @@ +/** + * @module getPackagesPackageNameVersionsVersionNameTarball + */ + +const { URL } = require("node:url"); + +module.exports = { + docs: { + summary: "Previously undocumented endpoint. Allows for installation of a package.", + responses: [ + { + 302: { + description: "Redirect to the GitHub tarball URL." + } + } + ] + }, + endpoint: { + method: "GET", + paths: [ + "/api/packages/:packageName/versions/:versionName/tarball", + "/api/themes/:packageName/versions/:versionName/tarball" + ], + rateLimit: "generic", + successStatus: 302, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + packageName: (context, req) => { return context.query.packageName(req); }, + versionName: (context, req) => { return context.query.engine(req.params.versionName); } + }, + + /** + * @async + * @memberof getPackagesPackageNameVersionsVersionNameTarball + * @function logic + * @desc Get the tarball of a specific package version. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @returns {sso} + */ + async logic(params, context) { + + // First ensure our version is valid + if (params.versionName === false) { + // since query.engine gives false if invalid, we can check the truthiness + // but returning early uses less compute, as a false version will never be found + const sso = new context.sso(); + + return sso.notOk().addShort("not_found") + .addMessage("The version provided is invalid."); + } + + const pack = await context.database.getPackageVersionByNameAndVersion( + params.packageName, + params.versionName + ); + + if (!pack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pack) + .addCalls("db.getPackageVersionByNameAndVersion", pack); + } + + const save = await context.database.updatePackageIncrementDownloadByName(params.packageName); + + if (!save.ok) { + context.logger.generic(3, "Failed to Update Downloads Count", { + type: "object", + obj: save.content + }); + // TODO We will probably want to revisit this after rewriting logging + // We don't want to exit on failed update to download count, only log + } + + // For simplicity, we will redirect the request to gh tarball url + // Allowing downloads to take place via GitHub Servers + // But before this is done, we will preform some checks to ensure the URL is correct/legit + const tarballURL = + pack.content.meta?.tarball_url ?? pack.content.meta?.dist?.tarball ?? ""; + let hostname = ""; + + // Try to extract the hostname + try { + const tbUrl = new URL(tarballURL); + hostname = tbUrl.hostname; + } catch (err) { + context.logger.generic( + 3, + `Malformed tarball URL for version ${params.versionName} of ${params.packageName}` + ); + const sso = new context.sso(); + + return sso.notOk().addContent(err) + .addShort("server_error") + .addMessage(`The URL to download this package seems invalid: ${tarballURL}.`); + } + + const allowedHostnames = [ + "codeload.github.com", + "api.github.com", + "github.com", + "raw.githubusercontent.com" + ]; + + if ( + !allowedHostnames.includes(hostname) && + process.env.PULSAR_STATUS !== "dev" + ) { + const sso = new context.sso(); + + return sso.notOk().addShort("server_error") + .addMessage(`Invalid Domain for Download Redirect: ${hostname}`); + } + + const sso = new context.ssoRedirect(); + return sso.isOk().addContent(tarballURL); + } +}; diff --git a/src/controllers/getPackagesSearch.js b/src/controllers/getPackagesSearch.js new file mode 100644 index 00000000..8eb18868 --- /dev/null +++ b/src/controllers/getPackagesSearch.js @@ -0,0 +1,93 @@ +/** + * @module getPackagesSearch + */ + +module.exports = { + docs: { + summary: "Searches all packages." + }, + endpoint: { + method: "GET", + paths: [ "/api/packages/search" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + sort: (context, req) => { return context.query.sort(req); }, + page: (context, req) => { return context.query.page(req); }, + direction: (context, req) => { return context.qeury.dir(req); }, + query: (context, req) => { return context.query.query(req); } + }, + + /** + * @async + * @memberof getPackagesSearch + * @function logic + * @desc Allows user to search through all packages. Using specified query params. + * @param {object} params - The available query parameters. + * @param {object} context - The Endpoint Context. + * @todo Use custom LCS search. + * @returns {ssoPaginate} + */ + async logic(params, context) { + // 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. + // This also means for now, the default sorting method will be downloads, not relevance. + + const packs = await context.database.simpleSearch( + params.query, + params.page, + params.direction, + params.sort + ); + + + if (!packs.ok) { + if (packs.short === "not_found") { + // Because getting not found from the search, means the users + // search just had no matches, we will specially handle this to return + // an empty array instead. + // TODO: Re-evaluate if this is needed. The empty result + // returning 'Not Found' has been resolved via the DB. + // But this check still might come in handy, so it'll be left in. + + const sso = new context.ssoPaginate(); + + return sso.isOk().addContent([]); + } + + const sso = new context.sso(); + + return sso.notOk().addContent(packs) + .addCalls("db.simpleSearch", packs); + } + + const newPacks = await context.utils.constructPackageObjectShort(packs.content); + + let packArray = null; + + if (Array.isArray(newPacks)) { + packArray = newPacks; + } else if (Object.keys(newPacks).length < 1) { + packArray = []; + // This also helps protect against misreturned searches. As in getting a 404 rather + // than empty search results. + // See: https://github.com/confused-Techie/atom-backend/issues/59 + } else { + packArray = [newPacks]; + } + + const ssoP = new context.ssoPaginate(); + + ssoP.total = packs.pagination.total; + ssoP.limit = packs.pagination.limit; + ssoP.buildLink(`${context.config.server_url}/api/packages/search`, packs.pagination.page, params); + + return ssoP.isOk().addContent(packArray); + } +}; diff --git a/src/controllers/getPat.js b/src/controllers/getPat.js new file mode 100644 index 00000000..fdec38f0 --- /dev/null +++ b/src/controllers/getPat.js @@ -0,0 +1,102 @@ +/** + * @module getPat + */ + +const superagent = require("superagent"); + +module.exports = { + docs: { + summary: "PAT Token Signup URL." + }, + endpoint: { + method: "GET", + paths: [ "/api/pat" ], + rateLimit: "auth", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + }, + endpointKind: "raw" + }, + + async logic(req, res, context) { + let params = { + token: req.query.token ?? "" + }; + + if (params.token === "") { + res.status(404).json({ + message: "Not Found: Parameter 'token' is empty." + }); + context.logger.httpLog(req, res); + return; + } + + try { + const userData = await superagent + .get("https://api.github.com/user") + .set({ Authorization: `Bearer ${params.token}` }) + .set({ "User-Agent": context.config.GH_USERAGENT }); + + if (userData.status !== 200) { + context.logger.generic(2, "User Data request to GitHub failed!", { + type: "object", + obj: userData + }); + res.status(500).json({ + message: `Application Error: Received HTTP Status ${userData.status} when contacting GitHub!` + }); + context.logger.httpLog(req, res); + return; + } + + // Now to build a valid user object + const username = userData.body.login; + const userId = userData.body.node_id; + const userAvatar = userData.body.avatar_url; + + const userExists = await context.database.getUserByNodeID(userId); + + if (userExists.ok) { + // If we plan to allow updating the user name or image, we would do so here + + // Now to redirect to the frontend site. + res.redirect(`https://web.pulsar-edit.dev/users?token=${params.token}`); + context.logger.httpLog(req, res); + return; + } + + let createdUser = await context.database.insertNewUser( + username, + userId, + userAvatar + ); + + if (!createdUser.ok) { + context.logger.generic(2, `Creating user failed! ${username}`); + res.status(500).json({ + message: "Application Error: Creating the user account failed!" + }); + context.logger.httpLog(req, res); + return; + } + + // Before returning, lets append their PAT token + createdUser.content.token = params.token; + + res.redirect( + `https://web.pulsar-edit.dev/users?token=${createdUser.cotnent.token}` + ); + context.logger.httpLog(req, res); + return; + } catch(err) { + context.logger.generic(2, "/api/pat Caught an Error!", { type: "error", err: err }); + res.status(500).json({ + message: "Application Error: The server encountered an error processing the request." + }); + context.logger.httpLog(req, res); + return; + } + } +}; diff --git a/src/controllers/getRoot.js b/src/controllers/getRoot.js new file mode 100644 index 00000000..d77f357c --- /dev/null +++ b/src/controllers/getRoot.js @@ -0,0 +1,31 @@ +/** + * @module getRoot + */ + +module.exports = { + docs: { + summary: "Non-Essential endpoint to return status message, and link to Swagger Instance." + }, + endpoint: { + method: "GET", + paths: [ "/" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: {}, + async logic(params, context) { + const str = ` +

Server is up and running Version ${context.server_version}

+ Swagger UI
+ Documentation + `; + + const sso = new context.ssoHTML(); + + return sso.isOk().addContent(str); + } +}; diff --git a/src/controllers/getStars.js b/src/controllers/getStars.js new file mode 100644 index 00000000..7115043d --- /dev/null +++ b/src/controllers/getStars.js @@ -0,0 +1,84 @@ +/** + * @module getStars + */ + +module.exports = { + docs: { + summary: "List the authenticated users' starred packages.", + responses: [ + { + 200: { + description: "Return a value similar to `GET /api/packages`, an array of package objects.", + content: {} + } + } + ] + }, + endpoint: { + method: "GET", + paths: [ "/api/stars" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); } + }, + + /** + * @async + * @memberOf getStars + * @function logic + * @desc Returns an array of all packages the authenticated user has starred. + */ + async logic(params, context) { + let user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addMessage("Please update your token if you haven't done so recently.") + .addCalls("auth.verifyAuth", user); + } + + let userStars = await context.database.getStarredPointersByUserID(user.content.id); + + if (!userStars.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(userStars) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getStarredPointersByUserID", userStars); + } + + if (userStars.content.length === 0) { + // If we have a return with no items, means the user has no stars + // And this will error out later when attempting to collect the data + // for the stars. So we will return early + const sso = new context.sso(); + + return sso.isOk().addContent([]); + } + + let packCol = await context.database.getPackageCollectionByID(userStars.content); + + if (!packCol.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packCol) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getStarredPointersByUserID", userStars) + .addCalls("db.getPackageCollectionByID", packCol); + } + + let newCol = await context.utils.constructPackageObjectShort(packCol.content); + + const sso = new context.sso(); + + return sso.isOk().addContent(newCol); + } +}; diff --git a/src/controllers/getThemes.js b/src/controllers/getThemes.js new file mode 100644 index 00000000..f989b196 --- /dev/null +++ b/src/controllers/getThemes.js @@ -0,0 +1,65 @@ +/** + * @module getThemes + */ + +module.exports = { + docs: { + summary: "List all packages that are themes.", + responses: { + 200: { + description: "A paginated response of themes.", + content: { + "application/json": "$packageObjectShortArray" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/themes" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "POST, GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + page: (context, req) => { return context.query.page(req); }, + sort: (context, req) => { return context.query.sort(req); }, + direction: (context, req) => { return context.query.dir(req); } + }, + + /** + * @async + * @memberOf getThemes + * @function logic + * @desc Returns all themes to the user. Based on any filters they've applied + * via query parameters. + * @returns {object} ssoPaginate + */ + async logic(params, context) { + + const packages = await context.database.getSortedPackages(params, true); + + if (!packages.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packages).addCalls("db.getSortedPackages", packages); + } + + const packObjShort = await context.utils.constructPackageObjectShort( + packages.content + ); + + const packArray = Array.isArray(packObjShort) ? packObjShort : [ packObjShort ]; + + const ssoP = new context.ssoPaginate(); + + ssoP.total = packages.pagination.total; + ssoP.limit = packages.pagination.limit; + ssoP.buildLink(`${context.config.server_url}/api/themes`, packages.pagination.page, params); + + return ssoP.isOk().addContent(packArray); + } +}; diff --git a/src/controllers/getThemesFeatured.js b/src/controllers/getThemesFeatured.js new file mode 100644 index 00000000..6bc83646 --- /dev/null +++ b/src/controllers/getThemesFeatured.js @@ -0,0 +1,46 @@ +/** + * @module getThemesFeatured + */ + +module.exports = { + docs: { + summary: "Display featured packages that are themes.", + responses: { + 200: { + description: "An array of featured themes.", + content: { + "application/json": "$packageObjectShortArray" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/themes/featured" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + // Currently we don't seem to utilize any query parameters here. + // We likely want to make this match whatever is used in getPackagesFeatured.js + }, + async logic(params, context) { + const col = await context.database.getFeaturedThemes(); + + if (!col.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(col).addCalls("db.getFeaturedThemes", col); + } + + const newCol = await context.utils.constructPackageObjectShort(col.content); + + const sso = new context.sso(); + + return sso.isOk().addContent(newCol); + } +}; diff --git a/src/controllers/getThemesSearch.js b/src/controllers/getThemesSearch.js new file mode 100644 index 00000000..97709720 --- /dev/null +++ b/src/controllers/getThemesSearch.js @@ -0,0 +1,70 @@ +/** + * @module getThemesSearch + */ + +module.exports = { + docs: { + summary: "Get featured packages that are themes. Previously undocumented.", + responses: { + 200: { + description: "A paginated response of themes.", + content: { + "application/json": "$packageObjectShortArray" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/themes/search" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + sort: (context, req) => { return context.query.sort(req); }, + page: (context, req) => { return context.query.page(req); }, + direction: (context, req) => { return context.query.dir(req); }, + query: (context, req) => { return context.query.query(req); } + }, + + async logic(params, context) { + const packs = await context.database.simpleSearch( + params.query, + params.page, + params.direction, + params.sort, + true + ); + + if (!packs.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packs) + .addCalls("db.simpleSearch", packs); + } + + const newPacks = await context.utils.constructPackageObjectShort(packs.content); + + let packArray = null; + + if (Array.isArray(newPacks)) { + packArray = newPacks; + } else if (Object.keys(newPacks).length < 1) { + packArray = []; + } else { + packArray = [ newPacks ]; + } + + const ssoP = new context.ssoPaginate(); + + ssoP.total = packs.pagination.total; + ssoP.limit = packs.pagination.limit; + ssoP.buildLink(`${context.config.server_url}/api/themes/search`, packs.pagination.page, params); + + return ssoP.isOk().addContent(packArray); + } +}; diff --git a/src/controllers/getUpdates.js b/src/controllers/getUpdates.js new file mode 100644 index 00000000..e8104195 --- /dev/null +++ b/src/controllers/getUpdates.js @@ -0,0 +1,42 @@ +/** + * @module getUpdates + */ + +module.exports = { + docs: { + summary: "List Pulsar Updates", + description: "Currently returns 'Not Implemented' as Squirrel AutoUpdate is not supported.", + responses: [ + { + 200: { + description: "Atom update feed, following the format expected by Squirrel.", + content: {} + } + } + ] + }, + endpoint: { + method: "GET", + paths: [ "/api/updates" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: {}, + + /** + * @async + * @memberof getUpdates + * @function logic + * @desc Used to retreive new editor update information. + * @todo This function has never been implemented within Pulsar. + */ + async logic(params, context) { + const sso = new context.sso(); + + return sso.notOk().addShort("not_supported"); + } +}; diff --git a/src/controllers/getUsers.js b/src/controllers/getUsers.js new file mode 100644 index 00000000..ee0c8ba1 --- /dev/null +++ b/src/controllers/getUsers.js @@ -0,0 +1,78 @@ +/** + * @module getUsers + */ + +module.exports = { + docs: { + summary: "Display details of the currently authenticated user. This endpoint is undocumented and is somewhat strange.", + description: "This endpoint only exists on the web version of the upstream API. Having no backend equivolent.", + responses: { + 200: { + description: "Details of the Authenticated User Account.", + content: { + "application/json": "$userObjectPrivate" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/users" ], + rateLimit: "auth", + successStatus: 200, + options: { + Allow: "GET", + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Access-Control-Allow-Credentials", + "Access-Control-Allow-Origin": "https://web.pulsar-edit.dev", + "Access-Control-Allow-Credentials": true + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); } + }, + async preLogic(req, res, context) { + res.header("Access-Control-Allow-Methods", "GET"); + res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, Access-Control-Allow-Credentials"); + res.header("Access-Control-Allow-Origin", "https://web.pulsar-edit.dev"); + res.header("Access-Control-Allow-Credentials", true); + }, + async postLogic(req, res, context) { + res.set({ "Access-Control-Allow-Credentials": true }); + }, + + /** + * @async + * @memberOf getUsers + * @desc Returns the currently authenticated Users User Details. + */ + async logic(params, context) { + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("auth.verifyAuth", user); + } + + // TODO We need to find a way to add the users published pacakges here + // When we do we will want to match the schema in ./docs/returns.md#userobjectfull + // Until now we will return the public details of their account. + const returnUser = { + username: user.content.username, + avatar: user.content.avatar, + created_at: user.content.created_at, + data: user.content.data, + node_id: user.content.node_id, + token: user.content.token, // Since this is for the auth user we can provide token + packages: [], // Included as it should be used in the future + }; + + // Now with the user, since this is the authenticated user we can return all account details. + + const sso = new context.sso(); + + return sso.isOk().addContent(returnUser); + } +}; diff --git a/src/controllers/getUsersLogin.js b/src/controllers/getUsersLogin.js new file mode 100644 index 00000000..b1af684c --- /dev/null +++ b/src/controllers/getUsersLogin.js @@ -0,0 +1,73 @@ +/** + * @module getUsersLogin + */ + +module.exports = { + docs: { + summary: "Display the details of any user, as well as the packages they have published.", + responses: { + 200: { + description: "The public details of a specific user.", + content: { + // This references the file name of a `./tests/models` model + "application/json": "$userObjectPublic" + } + }, + 404: { + description: "The User requested cannot be found.", + content: { + "application/json": "$message" + } + } + } + }, + endpoint: { + method: "GET", + paths: [ "/api/users/:login" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + login: (context, req) => { return context.query.login(req); } + }, + + /** + * @async + * @memberOf getUserLogin + * @desc Returns the user account details of another user. Including all + * packages published. + */ + async logic(params, context) { + let user = await context.database.getUserByName(params.login); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("db.getUserByName", user); + } + + // TODO We need to find a way to add the users published pacakges here + // When we do we will want to match the schema in ./docs/returns.md#userobjectfull + // Until now we will return the public details of their account. + + // Although now we have a user to return, but we need to ensure to strip any + // sensitive details since this return will go to any user. + const returnUser = { + username: user.content.username, + avatar: user.content.avatar, + created_at: `${user.content.created_at}`, + data: user.content.data ?? {}, + packages: [], // included as it should be used in the future + }; + + + const sso = new context.sso(); + + return sso.isOk().addContent(returnUser); + } +}; diff --git a/src/controllers/getUsersLoginStars.js b/src/controllers/getUsersLoginStars.js new file mode 100644 index 00000000..48536e75 --- /dev/null +++ b/src/controllers/getUsersLoginStars.js @@ -0,0 +1,84 @@ +/** + * @module getUsersLoginStars + */ + +module.exports = { + docs: { + summary: "List a user's starred packages.", + responses: [ + { + 200: { + description: "Return value is similar to `GET /api/packages`.", + content: {} + } + } + ] + }, + endpoint: { + method: "GET", + paths: [ "/api/users/:login/stars" ], + rateLimit: "generic", + successStatus: 200, + options: { + Allow: "GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + login: (context, req) => { return context.query.login(req); } + }, + async logic(params, context) { + const user = await context.database.getUserByName(params.login); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("db.getUserByName", user); + } + + let pointerCollection = await context.database.getStarredPointersByUserID(user.content.id); + + if (!pointerCollection.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pointerCollection) + .addCalls("db.getUserByName", user) + .addCalls("db.getStarredPointersByUserID", pointerCollection); + } + + // Since even if the pointerCollection is okay, it could be empty. Meaning the user + // has no stars. This is okay, but getPackageCollectionByID will fail, and result + // in a not found when discovering no packages by the ids passed, which is none. + // So we will catch the exception of pointerCollection being an empty array. + + if ( + Array.isArray(pointerCollection.content) && + pointerCollection.content.length === 0 + ) { + // Check for array to protect from an unexpected return + const sso = new context.sso(); + + return sso.isOk().addContent([]); + } + + let packageCollection = await context.database.getPackageCollectionByID( + pointerCollection.content + ); + + if (!packageCollection.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packageCollection) + .addCalls("db.getUserByName", user) + .addCalls("db.getStarredPointersByUserID", pointerCollection) + .addCalls("db.getPackageCollectionByID", packageCollection); + } + + packageCollection = await utils.constructPackageObjectShort(packageCollection.content); + + const sso = new context.sso(); + + return sso.isOk().addContent(packageCollection); + } +}; diff --git a/src/controllers/postPackages.js b/src/controllers/postPackages.js new file mode 100644 index 00000000..6c604816 --- /dev/null +++ b/src/controllers/postPackages.js @@ -0,0 +1,211 @@ +/** + * @module postPackages + */ + +module.exports = { + docs: { + summary: "Publishes a new Package." + }, + endpoint: { + method: "POST", + paths: [ + "/api/packages", + "/api/themes" + ], + rateLimit: "auth", + successStatus: 201, + options: { + Allow: "POST, GET", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + repository: (context, req) => { return context.query.repo(req); }, + auth: (context, req) => { return context.query.auth(req); } + }, + async postReturnHTTP(req, res, context, obj) { + // Return to user before wbehook call, so user doesn't wait on it + await context.webhook.alertPublishPackage(obj.webhook.pack, obj.webhook.user); + // Now to call for feature detection + let features = await context.vcs.featureDetection( + obj.featureDetection.user, + obj.featureDetection.ownerRepo, + obj.featureDetection.service + ); + + if (!features.ok) { + // TODO: LOG + return; + } + + // Then we know we don't need to apply any special features for a standard + // package, so we will check that early + if (features.content.standard) { + return; + } + + let featureApply = await context.database.applyFeatures( + features.content, + obj.webhook.pack.name, + obj.webhook.pack.version + ); + + if (!featureApply.ok) { + // TODO LOG + return; + } + + // Now everything has completed successfully + return; + }, + + async logic(params, context) { + const user = await context.auth.verifyAuth(params.auth, context.database); + // Check authentication + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("auth.verifyAuth", user); + } + + // Check repository format validity. + if (params.repository === "" || typeof params.repository !== "string") { + // repository format is invalid + const sso = new context.sso(); + + return sso.notOk().addShort("bad_repo") + .addMessage("Repository is missing."); + } + + // Currently though the repository is in `owner/repo` format, + // meanwhile needed functions expects just `repo` + + const repo = params.repository.split("/")[1]?.toLowerCase(); + + if (repo === undefined) { + const sso = new context.sso(); + + return sso.notOk().addShort("bad_repo") + .addMessage("Repository format is invalid."); + } + + // Now check if the name is banned. + const isBanned = await context.utils.isPackageNameBanned(repo); + + if (isBanned.ok) { + // The package name is banned + const sso = new context.sso(); + + return sso.notOk().addShort("server_error") + .addMessage("This package name is banned."); + } + + // Check that the package does NOT exists + // We will utilize our database.packageNameAvailability to see if the name is available + const nameAvailable = await context.database.packageNameAvailability(repo); + + if (!nameAvailable.ok) { + // We need to ensure the error is not found or otherwise + if (nameAvailable.short !== "not_found") { + // the server failed for some other bubbled reason + const sso = new context.sso(); + + return sso.notOk().addContent(nameAvailable) + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable); + } + + // But if the short is in fact "not_found" we can report the package as not being + // available at this name + const sso = new context.sso(); + + return sso.notOk().addShort("package_exists") + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable); + } + + // Now we know the package doesn't exist. And we want to check that the user + // has permissions to this package + const gitowner = await context.vcs.ownership(user.content, params.repository); + + if (!gitowner.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(gitowner) + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable) + .addCalls("vcs.ownership", gitowner); + } + + // Now knowing they own the git repo, and it doesn't exist here, lets publish. + // TODO: Stop hardcoding `git` as service + const newPack = await context.vcs.newPackageData( + user.content, + params.repository, + "git" + ); + + if (!newPack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(newPack) + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable) + .addCalls("vcs.ownership", gitowner) + .addCalls("vcs.newPackageData", newPack); + } + + // Now with valid package data, we can insert them into the DB + const insertedNewPack = await context.database.insertNewPackage(newPack.content); + + if (!insertedNewPack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(insertedNewPack) + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable) + .addCalls("vcs.ownership", gitowner) + .addCalls("vcs.newPackageData", newPack) + .addCalls("db.insertNewPackage", insertedNewPack); + } + + // Finally we can return what was actually put into the databse. + // Retreive the data from database.getPackageByName() and + // convert it inot a package object full format + const newDbPack = await context.database.getPackageByName(repo, true); + + if (!newDbPack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(newDbPack) + .addCalls("auth.verifyAuth", user) + .addCalls("db.packageNameAvailability", nameAvailable) + .addCalls("vcs.ownership", gitowner) + .addCalls("vcs.newPackageData", newPack) + .addCalls("db.insertNewPackage", insertedNewPack) + .addCalls("db.getPackageByName", newDbPack); + } + + const packageObjectFull = await context.utils.constructPackageObjectFull( + newDbPack.content + ); + + // Since this is a webhook call, we will return with some extra data + // Although this kinda defeats the point of the object builder + const sso = new context.sso(); + + sso.webhook = { + pack: packageObjectFull, + user: user.content + }; + + sso.featureDetection = { + user: user.content, + service: "git", // TODO stop hardcoding git + ownerRepo: params.repository + }; + + return sso.isOk().addContent(packageObjectFull); + } +}; diff --git a/src/controllers/postPackagesPackageNameStar.js b/src/controllers/postPackagesPackageNameStar.js new file mode 100644 index 00000000..ebe0cd99 --- /dev/null +++ b/src/controllers/postPackagesPackageNameStar.js @@ -0,0 +1,72 @@ +/** + * @module postPackagesPackageNameStar + */ + +module.exports = { + docs: { + summary: "Star a package.", + responses: { + 200: { + description: "A 'Package Object Full' of the modified package", + content: { + "application/json": "$packageObjectFull" + } + } + } + }, + endpoint: { + method: "POST", + paths: [ + "/api/packages/:packageName/star", + "/api/themes/:packageName/star" + ], + rateLimit: "auth", + successStatus: 200, + options: { + Allow: "DELETE, POST", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + auth: (context, req) => { return context.query.auth(req); }, + packageName: (context, req) => { return context.query.packageName(req); } + }, + async logic(params, context) { + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(user) + .addCalls("auth.verifyAuth", user); + } + + const star = await context.database.updateIncrementStar(user.content, params.packageName); + + if (!star.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(star) + .addCalls("auth.verifyAuth", user) + .addCalls("db.updateIncrementStar", star); + } + + // Now with a success we want to return the package back in this query + let pack = await context.database.getPackageByName(params.packageName, true); + + if (!pack.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(pack) + .addCalls("auth.verifyAuth", user) + .addCalls("db.updateIncrementStar", star) + .addCalls("db.getPackageByName", pack); + } + + pack = await context.utils.constructPackageObjectFull(pack.content); + + const sso = new context.sso(); + + return sso.isOk().addContent(pack); + } +}; diff --git a/src/controllers/postPackagesPackageNameVersions.js b/src/controllers/postPackagesPackageNameVersions.js new file mode 100644 index 00000000..5a5b4d56 --- /dev/null +++ b/src/controllers/postPackagesPackageNameVersions.js @@ -0,0 +1,229 @@ +/** + * @module postPackagesPackageNameVersions + */ + +module.exports = { + docs: { + summary: "Creates a new package version." + }, + endpoint: { + method: "POST", + paths: [ + "/api/packages/:packageName/versions", + "/api/themes/:packageName/versions" + ], + rateLimit: "auth", + successStatus: 201, + options: { + Allow: "POST", + "X-Content-Type-Options": "nosniff" + } + }, + params: { + rename: (context, req) => { return context.query.rename(req); }, + auth: (context, req) => { return context.query.auth(req); }, + packageName: (context, req) => { return context.query.packageName(req); } + }, + async postReturnHTTP(req, res, context, obj) { + // We use postReturnHTTP to ensure the user doesn't wait on these other actions + await context.webhook.alertPublishVersion(obj.webhook.pack, obj.webhook.user); + + // Now to call for feature detection + let features = await context.vcs.featureDetection( + obj.featureDetection.user, + obj.featureDetection.ownerRepo, + obj.featureDetection.service + ); + + if (!features.ok) { + context.logger.generic(3, features); + return; + } + + // THen we know we don't need to apply any special features for a standard + // package, so we will check that early + if (features.content.standard) { + return; + } + + let featureApply = await context.database.applyFeatures( + features.content, + obj.webhook.pack.name, + obj.webhook.pack.version + ); + + if (!featureApply.ok) { + logger.generic(3, featureApply); + return; + } + + // Otherwise we have completed successfully, while we could log, lets return + return; + }, + + async logic(params, context) { + // On renaming: + // When a package is being renamed, we will expect that packageName will + // match a previously published package. + // But then the `name` of their `package.json` will be different. + // And if they are, we expect that `rename` is true. Because otherwise it will fail. + // That's the methodology, the logic here just needs to catch up. + + const user = await context.auth.verifyAuth(params.auth, context.database); + + if (!user.ok) { + // TODO LOG + const sso = new context.sso(); + + return sso.notOk().addShort("unauthorized") + .addContent(user) + .addCalls("auth.verifyAuth", user) + .addMessage("User Authentication Failed when attempting to publish package version!"); + } + + context.logger.generic( + 6, + `${user.content.username} Attempting to publish a new package version - ${param.packageName}` + ); + + // To support a rename, we need to check if they have permissions over this + // packages new name. Which means we have to check if they have ownership AFTER + // we collect it's data. + + const packExists = await context.database.getPackageByName(params.packageName, true); + + if (!packExists.ok) { + // TODO LOG + const sso = new context.sso(); + + return sso.notOk().addShort("not_found") + .addContent(packExists) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addMessage("The server was unable to locate your package when publishing a new version."); + } + + // Get `owner/repo` string format from package. + let ownerRepo = context.utils.getOwnerRepoFromPackage(packExists.content.data); + + // Using our new VCS Service + // TODO: The "git" service shouldn't always be hardcoded. + let packMetadata = await vcs.newVersionData(user.content, ownerRepo, "git"); + + if (!packMetadata.ok) { + const sso = new context.sso(); + + return sso.notOk().addContent(packMetadata) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata); + } + + const newName = packMetadata.content.name; + + const currentName = packExists.content.name; + if (newName !== currentName && !params.rename) { + const sso = new context.sso(); + + return sso.notOk().addShort("bad_repo") + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata) + .addMessage("Package name doesn't match local name, with rename false."); + } + + // Else we will continue, and trust the name provided from the package as being accurate. + // And now we can ensure the user actually owns this repo, with our updated name. + + // By passing `packMetadata` explicitely, it ensures that data we use to check + // ownership is fresh, allowing for things like a package rename. + + const gitowner = await context.vcs.ownership(user.content, packMetadata.content); + + if (!gitowner.ok) { + const sso = new context.sso(); + + return sso.notOk().addShort("unauthorized") + .addContent(gitowner) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata) + .addCalls("vcs.ownership", gitowner) + .addMessage("User failed git ownership check!"); + } + + // Now the only thing left to do, is add this new version with the name from the package. + // And check again if the name is incorrect, since it'll need a new entry onto the names. + + const rename = newName !== currentName && params.rename; + + if (rename) { + // Before allowing the rename of a package, ensure the new name isn't banned + const isBanned = await context.utils.isPackageNameBanned(newName); + + if (isBanned.ok) { + const sso = new context.sso(); + + return sso.notOk().addShort("server_error") + .addContent(isBanned) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata) + .addCalls("vcs.ownership", gitowner) + .addMessage("This package Name is Banned on the Pulsar Registry"); + } + + const isAvailable = await context.database.packageNameAvailability(newName); + + if (isAvailable.ok) { + const sso = new context.sso(); + + return sso.notOk().addShort("server_error") + .addContent(isAvailable) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata) + .addCalls("vcs.ownership", gitowner) + .addCalls("db.packageNameAvailability", isAvailable) + .addMessage(`The Package Name: ${newName} is not available.`); + } + } + + // Now add the new version key + const addVer = await context.database.insertNewPackageVersion( + packMetadata.content, + rename ? currentName : null + ); + + if (!addVer.ok) { + // TODO Use hardcoded message until we can verify messages from db are safe + const sso = new context.sso(); + + return sso.notOk().addShort("server_error") + .addContent(addVer) + .addCalls("auth.verifyAuth", user) + .addCalls("db.getPackageByName", packExists) + .addCalls("vcs.newVersionData", packMetadata) + .addCalls("vcs.ownership", gitowner) + .addCalls("db.packageNameAvailability", isAvailable) + .addCalls("db.insertNewPackageVersion", addVer) + .addMessage("Failed to add the new package version to the database."); + } + + const sso = new context.sso(); + + // TODO the following reduces the good things an object builder gets us + sso.webhook = { + pack: packMetadata.content, + user: user.content + }; + + sso.featureDetection = { + user: user.content, + service: "git", // TODO stop hardcoding git + ownerRepo: ownerRepo + }; + + return sso.isOk().addContent(addVer.content); + } +}; diff --git a/src/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.js b/src/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.js new file mode 100644 index 00000000..401a34a9 --- /dev/null +++ b/src/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.js @@ -0,0 +1,42 @@ +/** + * @module postPackagesPackageNameVersionsVersionNameEventsUninstall + */ + +module.exports = { + docs: { + summary: "Previously undocumented endpoint. Since v1.0.2 has no effect.", + }, + endpoint: { + method: "POST", + paths: [ + "/api/packages/:packageName/versions/:versionName/events/uninstall", + "/api/themes/:packageName/versions/:versionName/events/uninstall" + ], + rateLimit: "auth", + successStatus: 201, + options: { + Allow: "POST", + "X-Content-Type-Options": "nosniff" + } + }, + params: {}, + async logic(params, context) { + /** + Used when a package is uninstalled, decreases the download count by 1. + Originally an undocumented endpoint. + The decision to return a '201' is based on how other POST endpoints return, + during a successful event. + This endpoint has now been deprecated, as it serves no useful features, + and on further examination may have been intended as a way to collect + data on users, which is not something we implement. + * Deprecated since v1.0.2 + * see: https://github.com/atom/apm/blob/master/src/uninstall.coffee + * While decoupling HTTP handling from logic, the function has been removed + entirely: https://github.com/pulsar-edit/package-backend/pull/171 + */ + + const sso = new context.sso(); + + return sso.isOk().addContent({ ok: true }); + } +} diff --git a/src/database.js b/src/database.js index 28ec4a81..9e02a358 100644 --- a/src/database.js +++ b/src/database.js @@ -93,13 +93,13 @@ async function packageNameAvailability(name) { : { ok: false, content: `${name} is not available to be used for a new package.`, - short: "Not Found", + short: "not_found", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -121,8 +121,8 @@ async function insertNewPackage(pack) { return await sqlStorage .begin(async (sqlTrans) => { const packageType = - typeof pack.metadata.themes === "string" && - pack.metadata.themes.match(/^(?:themes|ui)$/i) !== null + typeof pack.metadata.theme === "string" && + pack.metadata.theme.match(/^(?:syntax|ui)$/i) !== null ? "theme" : "package"; @@ -138,7 +138,7 @@ async function insertNewPackage(pack) { RETURNING pointer; `; } catch (e) { - throw `A constraint has been violated while inserting ${pack.name} in packages table.`; + throw `A constraint has been violated while inserting ${pack.name} in packages table: ${e.toString()}`; } if (!insertNewPack?.count) { @@ -204,11 +204,11 @@ async function insertNewPackage(pack) { }) .catch((err) => { return typeof err === "string" - ? { ok: false, content: err, short: "Server Error" } + ? { ok: false, content: err, short: "server_error" } : { ok: false, content: `A generic error occurred while inserting ${pack.name} package`, - short: "Server Error", + short: "server_error", error: err, }; }); @@ -337,11 +337,11 @@ async function insertNewPackageVersion(packJSON, oldName = null) { }) .catch((err) => { return typeof err === "string" - ? { ok: false, content: err, short: "Server Error" } + ? { ok: false, content: err, short: "server_error" } : { ok: false, content: `A generic error occured while inserting the new package version ${packJSON.name}`, - short: "Server Error", + short: "server_error", error: err, }; }); @@ -371,7 +371,7 @@ async function applyFeatures(featureObj, packName, packVersion) { return { ok: false, content: `Unable to find the pointer of ${packName}`, - short: "Not Found", + short: "not_found", }; } @@ -388,7 +388,7 @@ async function applyFeatures(featureObj, packName, packVersion) { return { ok: false, content: `Unable to set 'has_snippets' flag to true for ${packName}`, - short: "Server Error", + short: "server_error", }; } } @@ -404,7 +404,7 @@ async function applyFeatures(featureObj, packName, packVersion) { return { ok: false, content: `Unable to set 'has_grammar' flag to true for ${packName}`, - short: "Server Error", + short: "server_error", }; } } @@ -424,7 +424,7 @@ async function applyFeatures(featureObj, packName, packVersion) { return { ok: false, content: `Unable to add supportedLanguages to ${packName}`, - short: "Server Error", + short: "server_error", }; } } @@ -436,7 +436,7 @@ async function applyFeatures(featureObj, packName, packVersion) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err.toString(), // TODO this must be implemented within the logger // seems the custom PostgreSQL error object doesn't have native to string methods // or otherwise logging the error within an object doesn't trigger the toString method @@ -469,7 +469,7 @@ async function insertNewPackageName(newName, oldName) { return { ok: false, content: `Unable to find the original pointer of ${oldName}`, - short: "Not Found", + short: "not_found", }; } @@ -516,7 +516,7 @@ async function insertNewPackageName(newName, oldName) { : { ok: false, content: `A generic error occurred while inserting the new package name ${newName}`, - short: "Server Error", + short: "server_error", error: err, }; }); @@ -552,7 +552,7 @@ async function insertNewUser(username, id, avatar) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -605,14 +605,13 @@ async function getPackageByName(name, user = false) { : { ok: false, content: `package ${name} not found.`, - short: "Not Found", + short: "not_found", }; } catch (err) { return { ok: false, - content: "Generic Error", - short: "Server Error", - error: err, + content: err, + short: "server_error" }; } } @@ -639,13 +638,13 @@ async function getPackageByNameSimple(name) { : { ok: false, content: `Package ${name} not found.`, - short: "Not Found", + short: "not_found", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -675,13 +674,13 @@ async function getPackageVersionByNameAndVersion(name, version) { : { ok: false, content: `Package ${name} and Version ${version} not found.`, - short: "Not Found", + short: "not_found", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -715,12 +714,12 @@ async function getPackageCollectionByName(packArray) { return command.count !== 0 ? { ok: true, content: command } - : { ok: false, content: "No packages found.", short: "Not Found" }; + : { ok: false, content: "No packages found.", short: "not_found" }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -753,7 +752,7 @@ async function getPackageCollectionByID(packArray) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -801,13 +800,13 @@ async function updatePackageStargazers(name, pointer = null) { : { ok: false, content: "Unable to Update Package Stargazers", - short: "Server Error", + short: "server_error", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -843,7 +842,7 @@ async function updatePackageIncrementDownloadByName(name) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -873,13 +872,13 @@ async function updatePackageDecrementDownloadByName(name) { : { ok: false, content: "Unable to decrement Package Download Count", - short: "Server Error", + short: "server_error", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -955,11 +954,11 @@ async function removePackageByName(name, exterminate = false) { }) .catch((err) => { return typeof err === "string" - ? { ok: false, content: err, short: "Server Error" } + ? { ok: false, content: err, short: "server_error" } : { ok: false, content: `A generic error occurred while inserting ${name} package`, - short: "Server Error", + short: "server_error", error: err, }; }); @@ -988,7 +987,7 @@ async function removePackageVersion(packName, semVer) { return { ok: false, content: `Unable to find the pointer of ${packName}`, - short: "Not Found", + short: "not_found", }; } @@ -1019,7 +1018,7 @@ async function removePackageVersion(packName, semVer) { return { ok: false, content: `Unable to remove ${semVer} version of ${packName} package.`, - short: "Not Found", + short: "not_found", }; } @@ -1030,11 +1029,11 @@ async function removePackageVersion(packName, semVer) { }) .catch((err) => { return typeof err === "string" - ? { ok: false, content: err, short: "Server Error" } + ? { ok: false, content: err, short: "server_error" } : { ok: false, content: `A generic error occurred while inserting ${packName} package`, - short: "Server Error", + short: "server_error", error: err, }; }); @@ -1096,14 +1095,13 @@ async function getUserByName(username) { : { ok: false, content: `Unable to query for user: ${username}`, - short: "Not Found", + short: "not_found", }; } catch (err) { return { ok: false, - content: "Generic Error", - short: "Server Error", - error: err, + content: err, + short: "server_error" }; } } @@ -1128,7 +1126,7 @@ async function getUserByNodeID(id) { return { ok: false, content: `Unable to get User By NODE_ID: ${id}`, - short: "Not Found", + short: "not_found", }; } @@ -1137,13 +1135,13 @@ async function getUserByNodeID(id) { : { ok: false, content: `Unable to get User By NODE_ID: ${id}`, - short: "Server Error", + short: "server_error", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1169,7 +1167,7 @@ async function getUserByID(id) { return { ok: false, content: `Unable to get user by ID: ${id}`, - short: "Server Error", + short: "server_error", }; } @@ -1178,13 +1176,13 @@ async function getUserByID(id) { : { ok: false, content: `Unable to get user by ID: ${id}`, - short: "Server Error", + short: "server_error", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1208,7 +1206,7 @@ async function updateIncrementStar(user, pack) { return { ok: false, content: `Unable to find package ${pack} to star.`, - short: "Not Found", + short: "not_found", }; } @@ -1231,7 +1229,7 @@ async function updateIncrementStar(user, pack) { return { ok: false, content: `Failed to Star the Package`, - short: "Server Error", + short: "server_error", }; } @@ -1247,6 +1245,9 @@ async function updateIncrementStar(user, pack) { content: `Package Successfully Starred`, }; } catch (e) { + // TODO: While the comment below is accurate + // It's also worth noting that this catch will return success + // If the starring user does not exist. Resulting in a false positive // Catch the primary key violation on (package, userid), // Sinche the package is already starred by the user, we return ok. return { @@ -1258,7 +1259,7 @@ async function updateIncrementStar(user, pack) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1282,7 +1283,7 @@ async function updateDecrementStar(user, pack) { return { ok: false, content: `Unable to find package ${pack} to unstar.`, - short: "Not Found", + short: "not_found", }; } @@ -1312,7 +1313,7 @@ async function updateDecrementStar(user, pack) { return { ok: false, content: "Failed to Unstar the Package", - short: "Server Error", + short: "server_error", }; } @@ -1331,7 +1332,7 @@ async function updateDecrementStar(user, pack) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1368,7 +1369,7 @@ async function getStarredPointersByUserID(userid) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1409,7 +1410,7 @@ async function getStarringUsersByPointer(pointer) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1438,7 +1439,7 @@ async function simpleSearch(term, page, dir, sort, themes = false) { return { ok: false, content: `Unrecognized Sorting Method Provided: ${sort}`, - short: "Server Error", + short: "server_error", }; } @@ -1499,7 +1500,7 @@ async function simpleSearch(term, page, dir, sort, themes = false) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1631,7 +1632,7 @@ async function getSortedPackages(opts, themes = false) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err.toString(), }; } @@ -1683,13 +1684,13 @@ async function authStoreStateKey(stateKey) { : { ok: false, content: `The state key has not been saved on the database.`, - short: "Server Error", + short: "server_error", }; } catch (err) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } @@ -1732,7 +1733,7 @@ async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { return { ok: false, content: "The provided state key was not set for the auth login.", - short: "Not Found", + short: "not_found", }; } @@ -1744,7 +1745,7 @@ async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { return { ok: false, content: "The provided state key is expired for the auth login.", - short: "Not Found", + short: "not_found", }; } @@ -1753,7 +1754,7 @@ async function authCheckAndDeleteStateKey(stateKey, timestamp = null) { return { ok: false, content: "Generic Error", - short: "Server Error", + short: "server_error", error: err, }; } diff --git a/src/dev_server.js b/src/dev_server.js index 3934fcc6..f1b98ad6 100644 --- a/src/dev_server.js +++ b/src/dev_server.js @@ -39,7 +39,7 @@ async function test() { process.env.PORT = 8080; } - const app = require("./main.js"); + const app = require("./setupEndpoints.js"); const logger = require("./logger.js"); const database = require("./database.js"); // We can only require these items after we have set our env variables diff --git a/src/handlers/common_handler.js b/src/handlers/common_handler.js deleted file mode 100644 index 5eeec3e2..00000000 --- a/src/handlers/common_handler.js +++ /dev/null @@ -1,290 +0,0 @@ -/** - * @module common_handler - * @desc Provides a simplistic way to refer to implement common endpoint returns. - * So these can be called as an async function without more complex functions, reducing - * verbosity, and duplication within the codebase. - * @implements {logger} - */ - -const logger = require("../logger.js"); - -/** - * @async - * @function handleError - * @desc Generic error handler mostly used to reduce the duplication of error handling in other modules. - * It checks the short error string and calls the relative endpoint. - * Note that it's designed to be called as the last async function before the return. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @param {object} obj - the Raw Status Object of the User, expected to return from `VerifyAuth`. - */ -async function handleError(req, res, obj, num) { - switch (obj.short) { - case "Not Found": - await notFound(req, res); - break; - - case "Bad Repo": - await badRepoJSON(req, res, num); - break; - - case "Bad Package": - await badPackageJSON(req, res, num); - break; - - case "No Repo Access": - case "Bad Auth": - await authFail(req, res, obj, num); - break; - - case "Package Exists": - await packageExists(req, res); - break; - - case "File Not Found": - case "Server Error": - default: - await serverError(req, res, obj.content, num); - break; - } -} - -/** - * @async - * @function handleDetailedError - * @desc Less generic error handler than `handleError()`. Used for returned the - * improved error messages to users. Where instead of only returning an error - * `message` it will return `message` and `details`. Providing better insight into - * what has gone wrong with the server. - * Additionally this will aim to simplify error handling by not handing off the - * handling to additional functions. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @param {object} obj - The Object provided to return the error message. - * @param {string} obj.short - The recognized Short Code string for error handling. - * @param {string} obj.content - The detailed user friendly content of what's gone wrong. - */ -async function handleDetailedError(req, res, obj) { - switch (obj.short) { - case "Not Found": - res.status(404).json({ - message: "Not Found", - details: obj.content, - }); - logger.httpLog(req, res); - break; - case "Bad Repo": - res.status(400).json({ - message: - "That repo does not exist, isn't a Pulsar package, or pulsarbot does not have access.", - details: obj.content, - }); - logger.httpLog(req, res); - break; - case "Bad Package": - res.status(400).json({ - message: "The package.json at owner/repo isn't valid.", - details: obj.content, - }); - logger.httpLog(req, res); - break; - case "No Repo Access": - case "Bad Auth": - res.status(401).json({ - message: - "Requires authentication. Please update your token if you haven't done so recently.", - details: obj.content, - }); - logger.httpLog(req, res); - break; - case "File Not Found": - case "Server Error": - default: - res.status(500).json({ - message: "Application Error", - details: obj.content, - }); - logger.httpLog(req, res); - break; - } - return; -} - -/** - * @async - * @function authFail - * @desc Will take the failed user object from VerifyAuth, and respond for the endpoint as - * either a "Server Error" or a "Bad Auth", whichever is correct based on the Error bubbled from VerifyAuth. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @param {object} user - The Raw Status Object of the User, expected to return from `VerifyAuth`. - * @implements {MissingAuthJSON} - * @implements {ServerErrorJSON} - * @implements {logger.HTTPLog} - */ -async function authFail(req, res, user, num) { - switch (user.short) { - case "Bad Auth": - case "Auth Fail": - case "No Repo Access": // support for being passed a git return. - await missingAuthJSON(req, res); - break; - default: - await serverError(req, res, user.content, num); - break; - } -} - -/** - * @async - * @function serverError - * @desc Returns a standard Server Error to the user as JSON. Logging the detailed error message to the server. - * ###### Setting: - * * Status Code: 500 - * * JSON Response Body: message: "Application Error" - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @param {string} err - The detailed error message to log server side. - * @implements {logger.HTTPLog} - * @implements {logger.generic} - */ -async function serverError(req, res, err, num) { - res.status(500).json({ message: "Application Error" }); - logger.httpLog(req, res); - logger.generic(3, "Returning Server Error in common", { - type: "http", - req: req, - res: res, - }); -} - -/** - * @async - * @function notFound - * @desc Standard endpoint to return the JSON Not Found error to the user. - * ###### Setting: - * * Status Code: 404 - * * JSON Respone Body: message: "Not Found" - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function notFound(req, res) { - res.status(404).json({ message: "Not Found" }); - logger.httpLog(req, res); -} - -/** - * @async - * @function notSupported - * @desc Returns a Not Supported message to the user. - * ###### Setting: - * * Status Code: 501 - * * JSON Response Body: message: "While under development this feature is not supported." - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function notSupported(req, res) { - const message = "While under development this feature is not supported."; - res.status(501).json({ message }); - logger.httpLog(req, res); -} - -/** - * @async - * @function siteWideNotFound - * @desc Returns the SiteWide 404 page to the end user. - * ###### Setting Currently: - * * Status Code: 404 - * * JSON Response Body: message: "This is a standin for the proper site wide 404 page." - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function siteWideNotFound(req, res) { - res - .status(404) - .json({ message: "This is a standin for the proper site wide 404 page." }); - logger.httpLog(req, res); -} - -/** - * @async - * @function badRepoJSON - * @desc Returns the BadRepoJSON message to the user. - * ###### Setting: - * * Status Code: 400 - * * JSON Response Body: message: That repo does not exist, isn't an atom package, or atombot does not have access. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function badRepoJSON(req, res, num) { - const message = - "That repo does not exist, isn't an atom package, or atombot does not have access."; - res.status(400).json({ message }); - logger.httpLog(req, res); -} - -/** - * @async - * @function badPackageJSON - * @desc Returns the BadPackageJSON message to the user. - * ###### Setting: - * * Status Code: 400 - * * JSON Response Body: message: The package.json at owner/repo isn't valid. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function badPackageJSON(req, res, num) { - const message = "The package.json at owner/repo isn't valid."; - res.status(400).json({ message }); - logger.httpLog(req, res); -} - -/** - * @function packageExists - * @desc Returns the PackageExist message to the user. - * ###### Setting: - * * Status Code: 409 - * * JSON Response Body: message: "A Package by that name already exists." - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function packageExists(req, res) { - res.status(409).json({ message: "A Package by that name already exists." }); - logger.httpLog(req, res); -} - -/** - * @function missingAuthJSON - * @desc Returns the MissingAuth message to the user. - * ###### Setting: - * * Status Code: 401 - * * JSON Response Body: message: "Requires authentication. Please update your token if you haven't done so recently." - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @implements {logger.HTTPLog} - */ -async function missingAuthJSON(req, res) { - const message = - "Requires authentication. Please update your token if you haven't done so recently."; - res.status(401).json({ message }); - logger.httpLog(req, res); -} - -module.exports = { - authFail, - badRepoJSON, - badPackageJSON, - handleError, - notFound, - notSupported, - packageExists, - serverError, - siteWideNotFound, - handleDetailedError, -}; diff --git a/src/handlers/delete_package_handler.js b/src/handlers/delete_package_handler.js deleted file mode 100644 index 37e0a998..00000000 --- a/src/handlers/delete_package_handler.js +++ /dev/null @@ -1,204 +0,0 @@ -/** - * @module delete_package_handler - * @desc Endpoint Handlers for every DELETE Request that relates to packages themselves - */ - -const logger = require("../logger.js"); -const utils = require("../utils.js"); - -/** - * @async - * @function deletePackagesName - * @desc Allows the user to delete a repo they have ownership of. - * @param {object} params - The query parameters - * @param {string} params.auth - The API key for the user - * @param {string} params.packageName - The name of the package - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @param {module} vcs - An instance of the `vcs.js` module - * @property {http_method} - DELETE - * @property {http_endpoint} - /api/packages/:packageName - */ -async function deletePackagesName(params, db, auth, vcs) { - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - // Lets also first check to make sure the package exists. - const packageExists = await db.getPackageByName(params.packageName, true); - - if (!packageExists.ok) { - return { - ok: false, - content: packageExists, - }; - } - - logger.generic( - 6, - `${params.packageName} Successfully executed 'db.getPackageByName()'` - ); - - // Get `owner/repo` string format from package. - const ownerRepo = utils.getOwnerRepoFromPackage(packageExists.content.data); - - const gitowner = await vcs.ownership(user.content, ownerRepo); - - if (!gitowner.ok) { - return { - ok: false, - content: gitowner, - }; - } - - logger.generic( - 6, - `${params.packageName} Successfully executed 'vcs.ownership()'` - ); - - // Now they are logged in locally, and have permission over the GitHub repo. - const rm = await db.removePackageByName(params.packageName); - - if (!rm.ok) { - logger.generic( - 6, - `${params.packageName} FAILED to execute 'db.removePackageByName'`, - { - type: "error", - err: rm, - } - ); - return { - ok: false, - content: rm, - }; - } - - logger.generic( - 6, - `${params.packageName} Successfully executed 'db.removePackageByName'` - ); - - return { - ok: true, - }; -} - -/** - * @async - * @function deletePackageStar - * @desc Used to remove a star from a specific package for the authenticated usesr. - * @param {object} params - The query parameters - * @param {string} params.auth - The API Key of the user - * @param {string} params.packageName - The name of the package - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @property {http_method} - DELETE - * @property {http_endpoint} - /api/packages/:packageName/star - */ -async function deletePackagesStar(params, db, auth) { - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - const unstar = await db.updateDecrementStar(user.content, params.packageName); - - if (!unstar.ok) { - return { - ok: false, - content: unstar, - }; - } - - // On a successful action here we will return an empty 201 - return { - ok: true, - }; -} - -/** - * @async - * @function deletePackageVersion - * @desc Allows a user to delete a specific version of their package. - * @param {object} params - The query parameters - * @param {string} params.auth - The API key of the user - * @param {string} params.packageName - The name of the package - * @param {string} params.versionName - The version of the package - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @param {module} vcs - An instance of the `vcs.js` module - * @property {http_method} - DELETE - * @property {http_endpoint} - /api/packages/:packageName/versions/:versionName - */ -async function deletePackageVersion(params, db, auth, vcs) { - // Moving this forward to do the least computationally expensive task first. - // Check version validity - if (params.versionName === false) { - return { - ok: false, - content: { - short: "Not Found", - }, - }; - } - - // Verify the user has local and remote permissions - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - // Lets also first check to make sure the package exists. - const packageExists = await db.getPackageByName(params.packageName, true); - - if (!packageExists.ok) { - return { - ok: false, - content: packageExists, - }; - } - - const gitowner = await vcs.ownership(user.content, packageExists.content); - - if (!gitowner.ok) { - return { - ok: false, - content: gitowner, - }; - } - - // Mark the specified version for deletion, if version is valid - const removeVersion = await db.removePackageVersion( - params.packageName, - params.versionName - ); - - if (!removeVersion.ok) { - return { - ok: false, - content: removeVersion, - }; - } - - return { ok: true }; -} - -module.exports = { - deletePackagesName, - deletePackagesStar, - deletePackageVersion, -}; diff --git a/src/handlers/get_package_handler.js b/src/handlers/get_package_handler.js deleted file mode 100644 index 873a2354..00000000 --- a/src/handlers/get_package_handler.js +++ /dev/null @@ -1,458 +0,0 @@ -/** - * @module get_package_handler - * @desc Endpoint Handlers for every GET Request that relates to packages themselves - */ - -const logger = require("../logger.js"); -const { server_url } = require("../config.js").getConfig(); -const utils = require("../utils.js"); -const { URL } = require("node:url"); - -/** - * @async - * @function getPackages - * @desc Endpoint to return all packages to the user. Based on any filtering - * theyved applied via query parameters. - * @param {object} params - The query parameters for this endpoint. - * @param {integer} params.page - The page to retreive - * @param {string} params.sort - The method to sort by - * @param {string} params.direction - The direction to sort with - * @param {string} params.serviceType - The service type to display - * @param {string} params.service - The service to display - * @param {string} params.serviceVersion - The service version to show - * @param {string} params.fileExtension - File extension to only show compatible - * grammar package's of. - * @param {module} db - An instance of the database - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages - */ -async function getPackages(params, db) { - const packages = await db.getSortedPackages(params); - - if (!packages.ok) { - logger.generic( - 3, - `getPackages-getSortedPackages Not OK: ${packages.content}` - ); - return { - ok: false, - content: packages, - }; - } - - const page = packages.pagination.page; - const totPage = packages.pagination.total; - const packObjShort = await utils.constructPackageObjectShort( - packages.content - ); - - // The endpoint using this function needs an array. - const packArray = Array.isArray(packObjShort) ? packObjShort : [packObjShort]; - - let link = `<${server_url}/api/packages?page=${page}&sort=${params.sort}&order=${params.direction}>; rel="self", <${server_url}/api/packages?page=${totPage}&sort=${params.sort}&order=${params.direction}>; rel="last"`; - - if (page !== totPage) { - link += `, <${server_url}/api/packages?page=${page + 1}&sort=${ - params.sort - }&order=${params.direction}>; rel="next"`; - } - - return { - ok: true, - link: link, - total: packages.pagination.count, - limit: packages.pagination.limit, - content: packArray, - }; -} - -/** - * @async - * @function getPackagesFeatured - * @desc Allows the user to retrieve the featured packages, as package object shorts. - * This endpoint was originally undocumented. The decision to return 200 is based off similar endpoints. - * Additionally for the time being this list is created manually, the same method used - * on Atom.io for now. Although there are plans to have this become automatic later on. - * @see {@link https://github.com/atom/apm/blob/master/src/featured.coffee|Source Code} - * @see {@link https://github.com/confused-Techie/atom-community-server-backend-JS/issues/23|Discussion} - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/featured - */ -async function getPackagesFeatured(db) { - // Returns Package Object Short array. - // TODO: Does not support engine query parameter as of now - const packs = await db.getFeaturedPackages(); - - if (!packs.ok) { - logger.generic( - 3, - `getPackagesFeatured-getFeaturedPackages Not OK: ${packs.content}` - ); - return { - ok: false, - content: packs, - }; - } - - const packObjShort = await utils.constructPackageObjectShort(packs.content); - - // The endpoint using this function needs an array. - const packArray = Array.isArray(packObjShort) ? packObjShort : [packObjShort]; - - return { - ok: true, - content: packArray, - }; -} - -/** - * @async - * @function getPackagesSearch - * @desc Allows user to search through all packages. Using their specified - * query parameter. - * @param {object} params - The query parameters - * @param {integer} params.page - The page to retreive - * @param {string} params.sort - The method to sort by - * @param {string} params.direction - The direction to sort with - * @param {string} params.query - The search query - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/search - * @todo Note: This **has** been migrated to the new DB, and is fully functional. - * The TODO here is to eventually move this to use the custom built in LCS search, - * rather than simple search. - */ -async function getPackagesSearch(params, db) { - // 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. - // This also means for now, the default sorting method will be downloads, not relevance. - - const packs = await db.simpleSearch( - params.query, - params.page, - params.direction, - params.sort - ); - - if (!packs.ok) { - if (packs.short === "Not Found") { - logger.generic( - 4, - "getPackagesSearch-simpleSearch Responding with Empty Array for Not Found Status" - ); - // Because getting not found from the search, means the users - // search just had no matches, we will specially handle this to return - // an empty array instead. - // TODO: Re-evaluate if this is needed. The empty result - // returning 'Not Found' has been resolved via the DB. - // But this check still might come in handy, so it'll be left in. - return { - ok: true, - content: [], - }; - } - logger.generic( - 3, - `getPackagesSearch-simpleSearch Not OK: ${packs.content}` - ); - return { - ok: false, - content: packs, - }; - } - - const page = packs.pagination.page; - const totPage = packs.pagination.total; - const newPacks = await utils.constructPackageObjectShort(packs.content); - - let packArray = null; - - if (Array.isArray(newPacks)) { - packArray = newPacks; - } else if (Object.keys(newPacks).length < 1) { - packArray = []; - logger.generic( - 4, - "getPackagesSearch-simpleSearch Responding with Empty Array for 0 Key Length Object" - ); - // This also helps protect against misreturned searches. As in getting a 404 rather - // than empty search results. - // See: https://github.com/confused-Techie/atom-backend/issues/59 - } else { - packArray = [newPacks]; - } - - const safeQuery = encodeURIComponent( - params.query.replace(/[<>"':;\\/]+/g, "") - ); - // now to get headers. - let link = `<${server_url}/api/packages/search?q=${safeQuery}&page=${page}&sort=${params.sort}&order=${params.direction}>; rel="self", <${server_url}/api/packages/search?q=${safeQuery}&page=${totPage}&sort=${params.sort}&order=${params.direction}>; rel="last"`; - - if (page !== totPage) { - link += `, <${server_url}/api/packages/search?q=${safeQuery}&page=${ - page + 1 - }&sort=${params.sort}&order=${params.direction}>; rel="next"`; - } - - return { - ok: true, - link: link, - total: packs.pagination.count, - limit: packs.pagination.limit, - content: packArray, - }; -} - -/** - * @async - * @function getPackagesDetails - * @desc Allows the user to request a single package object full, depending - * on the package included in the path parameter. - * @param {object} param - The query parameters - * @param {string} param.engine - The version of Pulsar to check compatibility with - * @param {string} param.name - The package name - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/:packageName - */ -async function getPackagesDetails(params, db) { - let pack = await db.getPackageByName(params.name, true); - - if (!pack.ok) { - logger.generic( - 3, - `getPackagesDetails-getPackageByName Not OK: ${pack.content}` - ); - return { - ok: false, - content: pack, - }; - } - - pack = await utils.constructPackageObjectFull(pack.content); - - if (params.engine !== false) { - // query.engine returns false if no valid query param is found. - // before using engineFilter we need to check the truthiness of it. - pack = await utils.engineFilter(pack, params.engine); - } - - return { - ok: true, - content: pack, - }; -} - -/** - * @async - * @function getPackagesStargazers - * @desc Endpoint returns the array of `star_gazers` from a specified package. - * Taking only the package wanted, and returning it directly. - * @param {object} params - The query parameters - * @param {string} params.packageName - The name of the package - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/:packageName/stargazers - */ -async function getPackagesStargazers(params, db) { - // The following can't be executed in user mode because we need the pointer - const pack = await db.getPackageByName(params.packageName); - - if (!pack.ok) { - return { - ok: false, - content: pack, - }; - } - - const stars = await db.getStarringUsersByPointer(pack.content); - - if (!stars.ok) { - return { - ok: false, - content: stars, - }; - } - - const gazers = await db.getUserCollectionById(stars.content); - - if (!gazers.ok) { - return { - ok: false, - content: gazers, - }; - } - - return { - ok: true, - content: gazers.content, - }; -} - -/** - * @async - * @function getPackagesVersion - * @desc Used to retrieve a specific version from a package. - * @param {object} params - The query parameters - * @param {string} params.packageName - The Package name we care about - * @param {string} params.versionName - The package version we care about - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/:packageName/versions/:versionName - */ -async function getPackagesVersion(params, db) { - // Check the truthiness of the returned query engine. - if (params.versionName === false) { - // we return a 404 for the version, since its an invalid format - return { - ok: false, - content: { - short: "Not Found", - }, - }; - } - // Now we know the version is a valid semver. - - const pack = await db.getPackageVersionByNameAndVersion( - params.packageName, - params.versionName - ); - - if (!pack.ok) { - return { - ok: false, - content: pack, - }; - } - - const packRes = await utils.constructPackageObjectJSON(pack.content); - - return { - ok: true, - content: packRes, - }; -} - -/** - * @async - * @function getPackagesVersionTarball - * @desc Allows the user to get the tarball for a specific package version. - * Which should initiate a download of said tarball on their end. - * @param {object} params - The query parameters - * @param {string} params.packageName - The name of the package - * @param {string} params.versionName - The version of the package - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/packages/:packageName/versions/:versionName/tarball - */ -async function getPackagesVersionTarball(params, db) { - // Now that migration has began we know that each version will have - // a tarball_url key on it, linking directly to the tarball from gh for that version. - - // we initially want to ensure we have a valid version. - if (params.versionName === false) { - // since query.engine gives false if invalid, we can just check if its truthy - // additionally if its false, we know the version will never be found. - return { - ok: false, - content: { - short: "Not Found", - }, - }; - } - - // first lets get the package - const pack = await db.getPackageVersionByNameAndVersion( - params.packageName, - params.versionName - ); - - if (!pack.ok) { - return { - ok: false, - content: pack, - }; - } - - const save = await db.updatePackageIncrementDownloadByName( - params.packageName - ); - - if (!save.ok) { - logger.generic(3, "Failed to Update Downloads Count", { - type: "object", - obj: save.content, - }); - logger.generic(3, "Failed to Update Downloads Count", { - type: "http", - req: req, - res: res, - }); - // we don't want to exit on a failed to update downloads count, but should be logged. - } - - // For simplicity, we will redirect the request to gh tarball url, to allow - // 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 ?? pack.content.meta?.dist?.tarball ?? ""; - let hostname = ""; - - // Try to extract the hostname - try { - const tbUrl = new URL(tarballURL); - hostname = tbUrl.hostname; - } catch (e) { - logger.generic( - 3, - `Malformed tarball URL for version ${params.versionName} of ${params.packageName}` - ); - return { - ok: false, - content: { - ok: false, - short: "Server Error", - content: e, - }, - }; - } - - const allowedHostnames = [ - "codeload.github.com", - "api.github.com", - "github.com", - "raw.githubusercontent.com", - ]; - - if ( - !allowedHostnames.includes(hostname) && - process.env.PULSAR_STATUS !== "dev" - ) { - return { - ok: false, - content: { - ok: false, - short: "Server Error", - content: `Invalid Domain for Download Redirect: ${hostname}`, - }, - }; - } - - return { - ok: true, - content: tarballURL, - }; -} - -module.exports = { - getPackages, - getPackagesFeatured, - getPackagesSearch, - getPackagesDetails, - getPackagesStargazers, - getPackagesVersion, - getPackagesVersionTarball, -}; diff --git a/src/handlers/oauth_handler.js b/src/handlers/oauth_handler.js deleted file mode 100644 index d04363da..00000000 --- a/src/handlers/oauth_handler.js +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @module oauth_handler - * @desc Endpoint Handlers for Authentication URLs - * @implements {config} - * @implements {common_handler} - */ - -const { GH_CLIENTID, GH_REDIRECTURI, GH_CLIENTSECRET, GH_USERAGENT } = - require("../config.js").getConfig(); -const common = require("./common_handler.js"); -const utils = require("../utils.js"); -const logger = require("../logger.js"); -const superagent = require("superagent"); -const database = require("../database.js"); - -/** - * @async - * @function getLogin - * @desc Endpoint used to redirect users to login. Users will reach GitHub OAuth Page - * based on the backends client id. A key from crypto module is retrieved and used as - * state parameter for GH authentication. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @property {http_method} - GET - * @property {http_endpoint} - /api/lgoin - */ -async function getLogin(req, res) { - // The first point of contact to log into the app. - // Since this will be the endpoint for a user to login, we need to redirect to GH. - // @see https://docs.github.com/en/developers/apps/building-oauth-apps/authorizing-oauth-apps - logger.generic(4, "New Hit on api/login"); - - // Generate a random key. - const stateKey = utils.generateRandomString(64); - - // Before redirect, save the key into the database. - const saveStateKey = await database.authStoreStateKey(stateKey); - if (!saveStateKey.ok) { - await common.handleError(req, res, saveStateKey); - return; - } - - res - .status(302) - .redirect( - `https://github.com/login/oauth/authorize?client_id=${GH_CLIENTID}&redirect_uri=${GH_REDIRECTURI}&state=${stateKey}&scope=public_repo%20read:org` - ); - logger.generic(4, `Generated a new key and made the Redirect for: ${req.ip}`); - logger.httpLog(req, res); -} - -/** - * @async - * @function getOauth - * @desc Endpoint intended to use as the actual return from GitHub to login. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @property {http_method} - GET - * @property {http_endpoint} - /api/oath - */ -async function getOauth(req, res) { - let params = { - state: req.query.state ?? "", - code: req.query.code ?? "", - }; - logger.generic(4, "Get OAuth Hit!"); - - // First we want to ensure that the received state key is valid. - const validStateKey = await database.authCheckAndDeleteStateKey(params.state); - if (!validStateKey.ok) { - logger.generic(3, `Provided State Key is NOT Valid!`); - await common.handleError(req, res, validStateKey); - return; - } - - logger.generic(4, "The State Key is Valid and has been Removed"); - - // Retrieve access token - const initialAuth = await superagent - .post(`https://github.com/login/oauth/access_token`) - .query({ - code: params.code, - redirect_uri: GH_REDIRECTURI, - client_id: GH_CLIENTID, - client_secret: GH_CLIENTSECRET, - }); - - const accessToken = initialAuth.body?.access_token; - - if (accessToken === null || initialAuth.body?.token_type === null) { - logger.generic(2, "Auth Request to GitHub Failed!", { - type: "object", - obj: initialAuth, - }); - await common.handleError(req, res, { - ok: false, - short: "Server Error", - content: initialAuth, - }); - return; - } - - try { - // Request the user data using the access token - const userData = await superagent - .get("https://api.github.com/user") - .set({ Authorization: `Bearer ${accessToken}` }) - .set({ "User-Agent": GH_USERAGENT }); - - if (userData.status !== 200) { - logger.generic(2, "User Data Request to GitHub Failed!", { - type: "object", - obj: userData, - }); - await common.handleError(req, res, { - ok: false, - short: "Server Error", - content: userData, - }); - return; - } - - // Now retrieve the user data thet we need to store into the DB. - const username = userData.body.login; - const userId = userData.body.node_id; - const userAvatar = userData.body.avatar_url; - - const userExists = await database.getUserByNodeID(userId); - - if (userExists.ok) { - logger.generic(4, `User Check Says User Exists: ${username}`); - // This means that the user does in fact already exist. - // And from there they are likely reauthenticating, - // But since we don't save any type of auth tokens, the user just needs a new one - // and we should return their new one to them. - - // Now we redirect to the frontend site. - res.redirect(`https://web.pulsar-edit.dev/users?token=${accessToken}`); - logger.httpLog(req, res); - return; - } - - // The user does not exist, so we save its data into the DB. - let createdUser = await database.insertNewUser( - username, - userId, - userAvatar - ); - - if (!createdUser.ok) { - logger.generic(2, `Creating User Failed! ${userObj.username}`); - await common.handleError(req, res, createdUser); - return; - } - - // Before returning, lets append their access token - createdUser.content.token = accessToken; - - // Now we redirect to the frontend site. - res.redirect( - `https://web.pulsar-edit.dev/users?token=${createdUser.content.token}` - ); - logger.httpLog(req, res); - } catch (err) { - logger.generic(2, "/api/oauth Caught an Error!", { - type: "error", - err: err, - }); - await common.handleError(req, res, err); - return; - } -} - -/** - * @async - * @function getPat - * @desc Endpoint intended to Allow users to sign up with a Pat Token. - * @param {object} req - The `Request` object inherited from the Express endpoint. - * @param {object} res - The `Response` object inherited from the Express endpoint. - * @property {http_method} - GET - * @property {http_endpoint} - /api/pat - */ -async function getPat(req, res) { - let params = { - token: req.query.token ?? "", - }; - - logger.generic(4, `Get Pat Hit!`); - - if (params.pat === "") { - logger.generic(3, "Pat Empty on Request"); - await common.handleError(req, res, { - ok: false, - short: "Not Found", - content: "Pat Empty on Request", - }); - return; - } - - try { - const userData = await superagent - .get("https://api.github.com/user") - .set({ Authorization: `Bearer ${params.token}` }) - .set({ "User-Agent": GH_USERAGENT }); - - if (userData.status !== 200) { - logger.generic(2, "User Data Request to GitHub Failed!", { - type: "object", - obj: userData, - }); - await common.handleError(req, res, { - ok: false, - short: "Server Error", - content: userData, - }); - return; - } - - // Now to build a valid user object - const username = userData.body.login; - const userId = userData.body.node_id; - const userAvatar = userData.body.avatar_url; - - const userExists = await database.getUserByNodeID(userId); - - if (userExists.ok) { - logger.generic(4, `User Check Says User Exists: ${username}`); - - // If we plan to allow updating the user name or image, we would do so here - - // Now we redirect to the frontend site. - res.redirect(`https://web.pulsar-edit.dev/users?token=${params.token}`); - logger.httpLog(req, res); - return; - } - - let createdUser = await database.insertNewUser( - username, - userId, - userAvatar - ); - - if (!createdUser.ok) { - logger.generic(2, `Creating User Failed! ${username}`); - await common.handleError(req, res, createdUser); - return; - } - - // Before returning, lets append their PAT token - createdUser.content.token = params.token; - - res.redirect( - `https://web.pulsar-edit.dev/users?token=${createdUser.content.token}` - ); - logger.httpLog(req, res); - } catch (err) { - logger.generic(2, "/api/pat Caught an Error!", { type: "error", err: err }); - await common.handleError(req, res, err); - return; - } -} - -module.exports = { - getLogin, - getOauth, - getPat, -}; diff --git a/src/handlers/package_handler.js b/src/handlers/package_handler.js deleted file mode 100644 index cbddbd71..00000000 --- a/src/handlers/package_handler.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @module package_handler - * @desc Exports individual files handling endpoints relating to Packages - */ - -const getPackageHandler = require("./get_package_handler.js"); -const postPackageHandler = require("./post_package_handler.js"); -const deletePackageHandler = require("./delete_package_handler.js"); - -module.exports = { - getPackages: getPackageHandler.getPackages, - postPackages: postPackageHandler.postPackages, - getPackagesFeatured: getPackageHandler.getPackagesFeatured, - getPackagesSearch: getPackageHandler.getPackagesSearch, - getPackagesDetails: getPackageHandler.getPackagesDetails, - deletePackagesName: deletePackageHandler.deletePackagesName, - postPackagesStar: postPackageHandler.postPackagesStar, - deletePackagesStar: deletePackageHandler.deletePackagesStar, - getPackagesStargazers: getPackageHandler.getPackagesStargazers, - postPackagesVersion: postPackageHandler.postPackagesVersion, - getPackagesVersion: getPackageHandler.getPackagesVersion, - getPackagesVersionTarball: getPackageHandler.getPackagesVersionTarball, - deletePackageVersion: deletePackageHandler.deletePackageVersion, - postPackagesEventUninstall: postPackageHandler.postPackagesEventUninstall, -}; diff --git a/src/handlers/post_package_handler.js b/src/handlers/post_package_handler.js deleted file mode 100644 index ad35afc0..00000000 --- a/src/handlers/post_package_handler.js +++ /dev/null @@ -1,445 +0,0 @@ -/** - * @module post_package_handler - * @desc Endpoint Handlers for every POST Request that relates to packages themselves - */ - -const logger = require("../logger.js"); -const utils = require("../utils.js"); - -/** - * @async - * @function postPackages - * @desc This endpoint is used to publish a new package to the backend server. - * Taking the repo, and your authentication for it, determines if it can be published, - * then goes about doing so. - * @param {object} params - The query parameters - * @param {string} params.repository - The `owner/repo` combo of the remote package - * @param {string} params.auth - The API key of the user - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @param {module} vcs - An instance of the `vcs.js` module - * @return {string} JSON object of new data pushed into the database, but stripped of - * sensitive informations like primary and foreign keys. - * @property {http_method} - POST - * @property {http_endpoint} - /api/packages - */ -async function postPackages(params, db, auth, vcs) { - const user = await auth.verifyAuth(params.auth, db); - logger.generic( - 6, - `${user.content.username} Attempting to Publish new package` - ); - // Check authentication. - if (!user.ok) { - logger.generic(3, `postPackages-verifyAuth Not OK: ${user.content}`); - return { - ok: false, - content: user, - }; - } - - // Check repository format validity. - if (params.repository === "") { - logger.generic(6, "Repository Format Invalid, returning error"); - // The repository format is invalid. - return { - ok: false, - content: { - short: "Bad Repo", - }, - }; - } - - // Currently though the repository is in `owner/repo` format, - // meanwhile needed functions expects just `repo` - - const repo = params.repository.split("/")[1]?.toLowerCase(); - - if (repo === undefined) { - logger.generic(6, "Repository determined invalid after failed split"); - // The repository format is invalid. - return { - ok: false, - content: { - short: "Bad Repo", - }, - }; - } - - // Now check if the name is banned. - const isBanned = await utils.isPackageNameBanned(repo); - - if (isBanned.ok) { - logger.generic(3, `postPackages Blocked by banned package name: ${repo}`); - // The package name is banned - return { - ok: false, - type: "detailed", - content: { - ok: false, - short: "Server Error", - content: "Package Name is banned.", - }, - }; - // ^^^ Replace with a more specific error handler once supported TODO - } - - // Check the package does NOT exists. - // We will utilize our database.packageNameAvailability to see if the name is available. - - const nameAvailable = await db.packageNameAvailability(repo); - - if (!nameAvailable.ok) { - // 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: ${nameAvailable.content}` - ); - // The server failed for some other bubbled reason, and is now encountering an error - return { - ok: false, - content: nameAvailable, - }; - } - // But if the short is then only "Not Found" we can report it as not being available - logger.generic( - 6, - "The name for the package is not available: aborting publish" - ); - // The package exists. - return { - ok: false, - content: { - short: "Package Exists", - }, - }; - } - - // Now we know the package doesn't exist. And we want to check that the user owns this repo on git. - const gitowner = await vcs.ownership(user.content, params.repository); - - if (!gitowner.ok) { - logger.generic(3, `postPackages-ownership Not OK: ${gitowner.content}`); - return { - ok: false, - content: gitowner, - }; - } - - // Now knowing they own the git repo, and it doesn't exist here, lets publish. - // TODO: Stop hardcoding `git` as service - const newPack = await vcs.newPackageData( - user.content, - params.repository, - "git" - ); - - if (!newPack.ok) { - logger.generic(3, `postPackages-createPackage Not OK: ${newPack.content}`); - return { - ok: false, - type: "detailed", - content: newPack, - }; - } - - // Now with valid package data, we can insert them into the DB. - const insertedNewPack = await db.insertNewPackage(newPack.content); - - if (!insertedNewPack.ok) { - logger.generic( - 3, - `postPackages-insertNewPackage Not OK: ${insertedNewPack.content}` - ); - return { - ok: false, - content: insertedNewPack, - }; - } - - // Finally we can return what was actually put into the database. - // Retrieve the data from database.getPackageByName() and - // convert it into Package Object Full format. - const newDbPack = await db.getPackageByName(repo, true); - - if (!newDbPack.ok) { - logger.generic( - 3, - `postPackages-getPackageByName (After Pub) Not OK: ${newDbPack.content}` - ); - return { - ok: false, - content: newDbPack, - }; - } - - const packageObjectFull = await utils.constructPackageObjectFull( - newDbPack.content - ); - - // Since this is a webhook call, we will return with some extra data - return { - ok: true, - content: packageObjectFull, - webhook: { - pack: packageObjectFull, - user: user.content, - }, - featureDetection: { - user: user.content, - service: "git", // TODO Stop hardcoding Git - ownerRepo: params.repository, - }, - }; -} - -/** - * @async - * @function postPackagesStar - * @desc Used to submit a new star to a package from the authenticated user. - * @param {object} params - The query parameters - * @param {string} params.auth - The API key of the user - * @param {string} params.packageName - The name of the package - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @property {http_method} - POST - * @property {http_endpoint} - /api/packages/:packageName/star - */ -async function postPackagesStar(params, db, auth) { - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - const star = await db.updateIncrementStar(user.content, params.packageName); - - if (!star.ok) { - return { - ok: false, - content: star, - }; - } - - // Now with a success we want to return the package back in this query - let pack = await db.getPackageByName(params.packageName, true); - - if (!pack.ok) { - return { - ok: false, - content: pack, - }; - } - - pack = await utils.constructPackageObjectFull(pack.content); - - return { - ok: true, - content: pack, - }; -} - -/** - * @async - * @function postPackagesVersion - * @desc Allows a new version of a package to be published. But also can allow - * a user to rename their application during this process. - * @param {object} params - The query parameters - * @param {boolean} params.rename - Whether or not to preform a rename - * @param {string} params.auth - The API key of the user - * @param {string} params.packageName - The name of the package - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @param {module} vcs - An instance of the `vcs.js` module - * @property {http_method} - POST - * @property {http_endpoint} - /api/packages/:packageName/versions - */ -async function postPackagesVersion(params, db, auth, vcs) { - // On renaming: - // When a package is being renamed, we will expect that packageName will - // match a previously published package. - // But then the `name` of their `package.json` will be different. - // And if they are, we expect that `rename` is true. Because otherwise it will fail. - // That's the methodology, the logic here just needs to catch up. - - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - logger.generic( - 6, - "User Authentication Failed when attempting to publish package version!" - ); - - return { - ok: false, - type: "detailed", - content: user, - }; - } - logger.generic( - 6, - `${user.content.username} Attempting to publish a new package version - ${params.packageName}` - ); - - // To support a rename, we need to check if they have permissions over this packages new name. - // Which means we have to check if they have ownership AFTER we collect it's data. - - const packExists = await db.getPackageByName(params.packageName, true); - - if (!packExists.ok) { - logger.generic( - 6, - `Seems Package does not exist when trying to publish new version - ${packExists.content}` - ); - return { - ok: false, - type: "detailed", - content: { - ok: false, - short: packExists.short, - content: - "The server was unable to locate your package when publishing a new version.", - }, - }; - } - - // Get `owner/repo` string format from package. - let ownerRepo = utils.getOwnerRepoFromPackage(packExists.content.data); - - // Using our new VCS Service - // TODO: The "git" Service shouldn't always be hardcoded. - let packMetadata = await vcs.newVersionData(user.content, ownerRepo, "git"); - - if (!packMetadata.ok) { - logger.generic(6, packMetadata.content); - return { - ok: false, - content: packMetadata, - }; - } - - const newName = packMetadata.content.name; - - const currentName = packExists.content.name; - if (newName !== currentName && !params.rename) { - logger.generic( - 6, - "Package JSON and Params Package Names don't match, with no rename flag" - ); - // Only return error if the names don't match, and rename isn't enabled. - return { - ok: false, - content: { - ok: false, - short: "Bad Repo", - content: "Package name doesn't match local name, with rename false", - }, - }; - } - - // Else we will continue, and trust the name provided from the package as being accurate. - // And now we can ensure the user actually owns this repo, with our updated name. - - // By passing `packMetadata` explicitely, it ensures that data we use to check - // ownership is fresh, allowing for things like a package rename. - - const gitowner = await vcs.ownership(user.content, packMetadata.content); - - if (!gitowner.ok) { - logger.generic(6, `User Failed Git Ownership Check: ${gitowner.content}`); - return { - ok: false, - content: gitowner, - }; - } - - // Now the only thing left to do, is add this new version with the name from the package. - // And check again if the name is incorrect, since it'll need a new entry onto the names. - - const rename = newName !== currentName && params.rename; - if (rename) { - // Before allowing the rename of a package, ensure the new name isn't banned. - - const isBanned = await utils.isPackageNameBanned(newName); - - if (isBanned.ok) { - logger.generic( - 3, - `postPackages Blocked by banned package name: ${newName}` - ); - return { - ok: false, - type: "detailed", - content: { - ok: false, - short: "Server Error", - content: "This Package Name is Banned on the Pulsar Registry.", - }, - }; - } - - const isAvailable = await db.packageNameAvailability(newName); - - if (isAvailable.ok) { - logger.generic( - 3, - `postPackages Blocked by new name ${newName} not available` - ); - return { - ok: false, - type: "detailed", - content: { - ok: false, - short: "Server Error", - content: `The Package Name: ${newName} is not available.`, - }, - }; - } - } - - // Now add the new Version key. - - const addVer = await db.insertNewPackageVersion( - packMetadata.content, - rename ? currentName : null - ); - - if (!addVer.ok) { - // TODO: Use hardcoded message until we can verify messages from the db are safe - // to pass directly to end users. - return { - ok: false, - type: "detailed", - content: { - ok: addVer.ok, - short: addVer.short, - content: "Failed to add the new package version to the database.", - }, - }; - } - - return { - ok: true, - content: addVer.content, - webhook: { - pack: packMetadata.content, - user: user.content, - }, - featureDetection: { - user: user.content, - service: "git", // TODO Stop hardcoding git - ownerRepo: ownerRepo, - }, - }; -} - -module.exports = { - postPackages, - postPackagesStar, - postPackagesVersion, -}; diff --git a/src/handlers/star_handler.js b/src/handlers/star_handler.js deleted file mode 100644 index 7781edb7..00000000 --- a/src/handlers/star_handler.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * @module star_handler - * @desc Handler for any endpoints whose slug after `/api/` is `star`. - */ - -const logger = require("../logger.js"); -const utils = require("../utils.js"); - -/** - * @async - * @function getStars - * @desc Endpoint for `GET /api/stars`. Whose endgoal is to return an array of all packages - * the authenticated user has stared. - * @param {object} param - The supported query parameters. - * @param {string} param.auth - The authentication API token - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/stars - */ -async function getStars(params, db, auth) { - let user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - logger.generic(3, "getStars auth.verifyAuth() Not OK", { - type: "object", - obj: user, - }); - return { - ok: false, - content: user, - }; - } - - let userStars = await db.getStarredPointersByUserID(user.content.id); - - if (!userStars.ok) { - logger.generic(3, "getStars database.getStarredPointersByUserID() Not OK", { - type: "object", - obj: userStars, - }); - return { - ok: false, - content: userStars, - }; - } - - if (userStars.content.length === 0) { - logger.generic(6, "getStars userStars Has Length of 0. Returning empty"); - // If we have a return with no items, means the user has no stars. - // And this will error out later when attempting to collect the data for the stars. - // So we will reutrn here - return { - ok: true, - content: [], - }; - } - - let packCol = await db.getPackageCollectionByID(userStars.content); - - if (!packCol.ok) { - logger.generic(3, "getStars database.getPackageCollectionByID() Not OK", { - type: "object", - obj: packCol, - }); - return { - ok: false, - content: packCol, - }; - } - - let newCol = await utils.constructPackageObjectShort(packCol.content); - - return { - ok: true, - content: newCol, - }; -} - -module.exports = { - getStars, -}; diff --git a/src/handlers/theme_handler.js b/src/handlers/theme_handler.js deleted file mode 100644 index 36dc2247..00000000 --- a/src/handlers/theme_handler.js +++ /dev/null @@ -1,187 +0,0 @@ -/** - * @module theme_handler - * @desc Endpoint Handlers relating to themes only. - * @implements {database} - * @implements {utils} - * @implements {logger} - * @implements {config} - */ - -const utils = require("../utils.js"); -const logger = require("../logger.js"); -const { server_url } = require("../config.js").getConfig(); - -/** - * @async - * @function getThemeFeatured - * @desc Used to retrieve all Featured Packages that are Themes. Originally an undocumented - * endpoint. Returns a 200 response based on other similar responses. - * Additionally for the time being this list is created manually, the same method used - * on Atom.io for now. Although there are plans to have this become automatic later on. - * @see {@link https://github.com/atom/apm/blob/master/src/featured.coffee|Source Code} - * @see {@link https://github.com/confused-Techie/atom-community-server-backend-JS/issues/23|Discussion} - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/themes/featured - */ -async function getThemeFeatured(db) { - // Returns Package Object Short Array - - let col = await db.getFeaturedThemes(); - - if (!col.ok) { - return { - ok: false, - content: col, - }; - } - - let newCol = await utils.constructPackageObjectShort(col.content); - - return { - ok: true, - content: newCol, - }; -} - -/** - * @async - * @function getThemes - * @desc Endpoint to return all Themes to the user. Based on any filtering - * they'ved applied via query parameters. - * @param {object} params - The query parameters that can operate on this endpoint. - * @param {integer} params.page - The page of results to retreive. - * @param {string} params.sort - The sort method to use. - * @param {string} params.direction - The direction to sort results. - * @param {module} db - An instance of the `database.js` module - * @returns {object} An HTTP ServerStatus. - * @property {http_method} - GET - * @property {http_endpoint} - /api/themes - */ -async function getThemes(params, db) { - const packages = await db.getSortedPackages(params, true); - - if (!packages.ok) { - logger.generic( - 3, - `getThemes-getSortedPackages Not OK: ${packages.content}` - ); - return { - ok: false, - content: packages, - }; - } - - const page = packages.pagination.page; - const totPage = packages.pagination.total; - const packObjShort = await utils.constructPackageObjectShort( - packages.content - ); - - const packArray = Array.isArray(packObjShort) ? packObjShort : [packObjShort]; - - let link = `<${server_url}/api/themes?page=${page}&sort=${params.sort}&order=${params.direction}>; rel="self", <${server_url}/api/themes?page=${totPage}&sort=${params.sort}&order=${params.direction}>; rel="last"`; - - if (page !== totPage) { - link += `, <${server_url}/api/themes?page=${page + 1}&sort=${ - params.sort - }&order=${params.direction}>; rel="next"`; - } - - return { - ok: true, - link: link, - total: packages.pagination.count, - limit: packages.pagination.limit, - content: packArray, - }; -} - -/** - * @async - * @function getThemesSearch - * @desc Endpoint to Search from all themes on the registry. - * @param {object} params - The query parameters from the initial request. - * @param {integer} params.page - The page number to return - * @param {string} params.sort - The method to use to sort - * @param {string} params.direction - The direction to sort - * @param {string} params.query - The search query to use - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/themes/search - */ -async function getThemesSearch(params, db) { - const packs = await db.simpleSearch( - params.query, - params.page, - params.direction, - params.sort, - true - ); - - if (!packs.ok) { - if (packs.short === "Not Found") { - logger.generic( - 4, - "getThemesSearch-simpleSearch Responding with Empty Array for Not Found Status" - ); - return { - ok: true, - content: [], - link: "", - total: 0, - limit: 0, - }; - } - - logger.generic(3, `getThemesSearch-simpleSearch Not OK: ${packs.content}`); - return { - ok: false, - content: packs, - }; - } - - const page = packs.pagination.page; - const totPage = packs.pagination.total; - const newPacks = await utils.constructPackageObjectShort(packs.content); - - let packArray = null; - - if (Array.isArray(newPacks)) { - packArray = newPacks; - } else if (Object.keys(newPacks).length < 1) { - packArray = []; - logger.generic( - 4, - "getThemesSearch-simpleSearch Responding with Empty Array for 0 key Length Object" - ); - } else { - packArray = [newPacks]; - } - - const safeQuery = encodeURIComponent( - params.query.replace(/[<>"':;\\/]+/g, "") - ); - // now to get headers. - let link = `<${server_url}/api/themes/search?q=${safeQuery}&page=${page}&sort=${params.sort}&order=${params.direction}>; rel="self", <${server_url}/api/themes/search?q=${safeQuery}&page=${totPage}&sort=${params.sort}&order=${params.direction}>; rel="last"`; - - if (page !== totPage) { - link += `, <${server_url}/api/themes/search?q=${safeQuery}&page=${ - page + 1 - }&sort=${params.sort}&order=${params.direction}>; rel="next"`; - } - - return { - ok: true, - content: packArray, - link: link, - total: packs.pagination.count, - limit: packs.pagination.limit, - }; -} - -module.exports = { - getThemeFeatured, - getThemes, - getThemesSearch, -}; diff --git a/src/handlers/update_handler.js b/src/handlers/update_handler.js deleted file mode 100644 index 05b26b7e..00000000 --- a/src/handlers/update_handler.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @module update_handler - * @desc Endpoint Handlers relating to updating the editor. - * @implments {command_handler} - */ - -const common = require("./common_handler.js"); - -/** - * @async - * @function getUpdates - * @desc Used to retrieve new editor update information. - * @property {http_method} - GET - * @property {http_endpoint} - /api/updates - * @todo This function has never been implemented on this system. Since there is currently no - * update methodology. - */ -async function getUpdates() { - return { - ok: false, - }; -} - -module.exports = { - getUpdates, -}; diff --git a/src/handlers/user_handler.js b/src/handlers/user_handler.js deleted file mode 100644 index 5eecf6e8..00000000 --- a/src/handlers/user_handler.js +++ /dev/null @@ -1,162 +0,0 @@ -/** - * @module user_handler - * @desc Handler for endpoints whose slug after `/api/` is `user`. - */ - -const logger = require("../logger.js"); -const utils = require("../utils.js"); - -/** - * @async - * @function getLoginStars - * @desc Endpoint that returns another users Star Gazers List. - * @param {object} params - The query parameters for the request - * @param {string} params.login - The username - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/users/:login/stars - */ -async function getLoginStars(params, db) { - let user = await db.getUserByName(params.login); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - let pointerCollection = await db.getStarredPointersByUserID(user.content.id); - - if (!pointerCollection.ok) { - return { - ok: false, - content: pointerCollection, - }; - } - - // Since even if the pointerCollection is okay, it could be empty. Meaning the user - // has no stars. This is okay, but getPackageCollectionByID will fail, and result - // in a not found when discovering no packages by the ids passed, which is none. - // So we will catch the exception of pointerCollection being an empty array. - - if ( - Array.isArray(pointerCollection.content) && - pointerCollection.content.length === 0 - ) { - // Check for array to protect from an unexpected return - return { - ok: true, - content: [], - }; - } - - let packageCollection = await db.getPackageCollectionByID( - pointerCollection.content - ); - - if (!packageCollection.ok) { - return { - ok: false, - content: packageCollection, - }; - } - - packageCollection = await utils.constructPackageObjectShort( - packageCollection.content - ); - - return { - ok: true, - content: packageCollection, - }; -} - -/** - * @async - * @function getAuthUser - * @desc Endpoint that returns the currently authenticated Users User Details - * @param {object} params - The query parameters for this endpoint - * @param {string} params.auth - The API Key - * @param {module} db - An instance of the `database.js` module - * @param {module} auth - An instance of the `auth.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/users - */ -async function getAuthUser(params, db, auth) { - const user = await auth.verifyAuth(params.auth, db); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - // TODO We need to find a way to add the users published pacakges here - // When we do we will want to match the schema in ./docs/returns.md#userobjectfull - // Until now we will return the public details of their account. - const returnUser = { - username: user.content.username, - avatar: user.content.avatar, - created_at: user.content.created_at, - data: user.content.data, - node_id: user.content.node_id, - token: user.content.token, // Since this is for the auth user we can provide token - packages: [], // Included as it should be used in the future - }; - - // Now with the user, since this is the authenticated user we can return all account details. - - return { - ok: true, - content: returnUser, - }; -} - -/** - * @async - * @function getUser - * @desc Endpoint that returns the user account details of another user. Including all packages - * published. - * @param {object} params - The query parameters - * @param {string} params.login - The Username we want to look for - * @param {module} db - An instance of the `database.js` module - * @property {http_method} - GET - * @property {http_endpoint} - /api/users/:login - */ -async function getUser(params, db) { - let user = await db.getUserByName(params.login); - - if (!user.ok) { - return { - ok: false, - content: user, - }; - } - - // TODO We need to find a way to add the users published pacakges here - // When we do we will want to match the schema in ./docs/returns.md#userobjectfull - // Until now we will return the public details of their account. - - // Although now we have a user to return, but we need to ensure to strip any sensitive details - // since this return will go to any user. - const returnUser = { - username: user.content.username, - avatar: user.content.avatar, - created_at: user.content.created_at, - data: user.content.data, - packages: [], // included as it should be used in the future - }; - - return { - ok: true, - content: returnUser, - }; -} - -module.exports = { - getLoginStars, - getAuthUser, - getUser, -}; diff --git a/src/main.js b/src/main.js deleted file mode 100644 index 6d1fd793..00000000 --- a/src/main.js +++ /dev/null @@ -1,1641 +0,0 @@ -/** - * @module main - * @desc The Main functionality for the entire server. Sets up the Express server, providing - * all endpoints it listens on. With those endpoints being further documented in `api.md`. - */ - -const express = require("express"); -const app = express(); - -const update_handler = require("./handlers/update_handler.js"); -const star_handler = require("./handlers/star_handler.js"); -const user_handler = require("./handlers/user_handler.js"); -const theme_handler = require("./handlers/theme_handler.js"); -const package_handler = require("./handlers/package_handler.js"); -const common_handler = require("./handlers/common_handler.js"); -const oauth_handler = require("./handlers/oauth_handler.js"); -const webhook = require("./webhook.js"); -const database = require("./database.js"); -const auth = require("./auth.js"); -const server_version = require("../package.json").version; -const logger = require("./logger.js"); -const query = require("./query.js"); -const vcs = require("./vcs.js"); -const rateLimit = require("express-rate-limit"); -const { MemoryStore } = require("express-rate-limit"); -const { RATE_LIMIT_AUTH, RATE_LIMIT_GENERIC } = - require("./config.js").getConfig(); - -// Define our Basic Rate Limiters -const genericLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: process.env.PULSAR_STATUS === "dev" ? 0 : RATE_LIMIT_GENERIC, // Limit each IP per window, 0 disables rate limit. - standardHeaders: true, // Return rate limit info in headers - legacyHeaders: true, // Legacy rate limit info in headers - store: new MemoryStore(), // Use default memory store - message: "Too many requests, please try again later.", // Message once limit is reached. - statusCode: 429, // HTTP Status Code once limit is reached. - handler: (request, response, next, options) => { - response.status(options.statusCode).json({ message: options.message }); - logger.httpLog(request, response); - }, -}); - -const authLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: process.env.PULSAR_STATUS === "dev" ? 0 : RATE_LIMIT_AUTH, // Limit each IP per window, 0 disables rate limit. - standardHeaders: true, // Return rate limit info on headers - legacyHeaders: true, // Legacy rate limit info in headers - store: new MemoryStore(), // use default memory store - message: "Too many requests, please try again later.", // message once limit is reached - statusCode: 429, // HTTP Status code once limit is reached. - handler: (request, response, next, options) => { - response.status(options.statusCode).json({ message: options.message }); - logger.httpLog(request, response); - }, -}); - -// ^^ Our two Rate Limiters ^^ these are essentially currently disabled. -// The reason being, the original API spec made no mention of rate limiting, so nor will we. -// But once we have surpassed feature parity, we will instead enable these limits, to help -// prevent overusage of the api server. With Auth having a lower limit, then non-authed requests. - -app.set("trust proxy", true); -// ^^^ Used to determine the true IP address behind the Google App Engine Load Balancer. -// This is need for the Authentication features to proper maintain their StateStore -// Hashmap. https://cloud.google.com/appengine/docs/flexible/nodejs/runtime#https_and_forwarding_proxies - -app.use("/swagger-ui", express.static("docs/swagger")); - -app.use((req, res, next) => { - // This adds a start to the request, logging the exact time a request was received. - req.start = Date.now(); - next(); -}); - -/** - * @web - * @ignore - * @path / - * @desc A non-essential endpoint, returning a status message, and the server version. - * @method GET - * @auth FALSE - */ -app.get("/", genericLimit, (req, res) => { - // While originally here in case this became the endpoint to host the - // frontend website, now that that is no longer planned, it can be used - // as a way to check the version of the server. Not needed, but may become helpful. - res.status(200).send(` -

Server is up and running Version ${server_version}


- Swagger UI
- Documentation - `); -}); - -app.options("/", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/oauth - * @desc OAuth Callback URL. Other details TBD. - * @method GET - * @auth FALSE - */ -app.get("/api/login", authLimit, async (req, res) => { - await oauth_handler.getLogin(req, res); -}); - -app.options("/api/login", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/oauth - * @desc OAuth Callback URL. Other details TDB. - * @method GET - * @auth FALSE - */ -app.get("/api/oauth", authLimit, async (req, res) => { - await oauth_handler.getOauth(req, res); -}); - -app.options("/api/oauth", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/pat - * @desc Pat Token Signup URL. - * @method GET - * @auth FALSE - */ -app.get("/api/pat", authLimit, async (req, res) => { - await oauth_handler.getPat(req, res); -}); - -app.options("/api/pat", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/:packType - * @desc List all packages. - * @method GET - * @auth false - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name page - * @location query - * @Ptype integer - * @default 1 - * @required false - * @Pdesc Indicate the page number to return. - * @param - * @name sort - * @Ptype string - * @location query - * @default downloads - * @valid downloads, created_at, updated_at, stars - * @required false - * @Pdesc The method to sort the returned pacakges by. - * @param - * @name direction - * @Ptype string - * @default desc - * @valid desc, asc - * @required false - * @Pdesc Which direction to list the results. If sorting by stars, can only be sorted by desc. - * @param - * @name service - * @Ptype string - * @required false - * @Pdesc A service to filter results by. - * @param - * @name serviceType - * @Ptype string - * @required false - * @valid provided, consumed - * @Pdesc The service type to filter results by. Must be supplied if a service is provided. - * @param - * @name serviceVersion - * @Ptype string - * @required false - * @Pdesc An optional (when providing a service) version to filter results by. - * @param - * @name fileExtension - * @Ptype string - * @required false - * @Pdesc The file extension to filter all results by. Must be just the file extension without any `.` - * @response - * @status 200 - * @Rtype application/json - * @Rdesc Returns a list of all packages. Paginated 30 at a time. Links to the next and last pages are in the 'Link' Header. - */ -app.get("/api/:packType", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": { - let ret = await package_handler.getPackages( - { - page: query.page(req), - sort: query.sort(req), - direction: query.dir(req), - serviceType: query.serviceType(req), - service: query.service(req), - serviceVersion: query.serviceVersion(req), - fileExtension: query.fileExtension(req), - }, - database - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // Since we know this is a paginated endpoint we will handle that here - res.append("Link", ret.link); - res.append("Query-Total", ret.total); - res.append("Query-Limit", ret.limit); - - res.status(200).json(ret.content); - logger.httpLog(req, res); - - break; - } - case "themes": { - let ret = await theme_handler.getThemes( - { - page: query.page(req), - sort: query.sort(req), - direction: query.dir(req), - }, - database - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // Since we know this is a paginated endpoint we will handle that here - res.append("Link", ret.link); - res.append("Query-Total", ret.total); - res.append("Query-Limit", ret.limit); - - res.status(200).json(ret.content); - logger.httpLog(req, res); - - break; - } - default: { - next(); - break; - } - } -}); - -/** - * @web - * @ignore - * @path /api/packages - * @desc Publishes a new Package. - * @method POST - * @auth true - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name repository - * @Ptype string - * @location query - * @required true - * @Pdesc The repository containing the plugin, in the form 'owner/repo'. - * @param - * @name Authentication - * @Ptype string - * @location header - * @required true - * @Pdesc A valid Atom.io token, in the 'Authorization' Header. - * @response - * @status 201 - * @Rtype application/json - * @Rdesc Successfully created, return created package. - * @response - * @status 400 - * @Rtype application/json - * @Rdesc Repository is inaccessible, nonexistant, not an atom package. Could be different errors returned. - * @Rexample { "message": "That repo does not exist, ins't an atom package, or atombot does not have access." }, { "message": "The package.json at owner/repo isn't valid." } - * @response - * @status 409 - * @Rtype application/json - * @Rdesc A package by that name already exists. - */ -app.post("/api/:packType", authLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - repository: query.repo(req), - auth: query.auth(req), - }; - - let ret = await package_handler.postPackages(params, database, auth, vcs); - - if (!ret.ok) { - if (ret.type === "detailed") { - await common_handler.handleDetailedError(req, res, ret.content); - return; - } else { - await common_handler.handleError(req, res, ret.content); - return; - } - } - - res.status(201).json(ret.content); - - // Return to user before webhook call, so user doesn't wait on it - await webhook.alertPublishPackage(ret.webhook.pack, ret.webhook.user); - // Now to call for feature detection - let features = await vcs.featureDetection( - ret.featureDetection.user, - ret.featureDetection.ownerRepo, - ret.featureDetection.service - ); - - if (!features.ok) { - logger.generic(3, features); - return; - } - - // Then we know we don't need to apply any special features for a standard - // package, so we will check that early - if (features.content.standard) { - return; - } - - let featureApply = await database.applyFeatures( - features.content, - ret.webhook.pack.name, - ret.wehbook.pack.version - ); - - if (!featureApply.ok) { - logger.generic(3, featureApply); - return; - } - - // Now everything has completed successfully - return; - - break; - default: - next(); - break; - } -}); - -app.options("/api/:packType", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "POST, GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } -}); - -/** - * @web - * @ignore - * @path /api/:packType/featured - * @desc Previously Undocumented endpoint. Used to return featured packages from all existing packages. - * @method GET - * @auth false - * @param - * @name packType - * @location path - * @Ptype string - * @valid packages, themes - * @required true - * @Pdesc The Package Type you want to request. - * @response - * @status 200 - * @Rdesc An array of packages similar to /api/packages endpoint. - */ -app.get("/api/:packType/featured", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": { - let ret = await package_handler.getPackagesFeatured(database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - } - case "themes": { - let ret = await theme_handler.getThemeFeatured(database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - } - default: { - next(); - break; - } - } -}); - -app.options("/api/:packType/featured", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } -}); - -/** - * @web - * @ignore - * @path /api/:packType/search - * @desc Searches all Packages. - * @method GET - * @auth false - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @valid packages, themes - * @Pdesc The Package Type you want. - * @param - * @name q - * @Ptype string - * @required true - * @location query - * @Pdesc Search query. - * @param - * @name page - * @Ptype integer - * @required false - * @location query - * @Pdesc The page of search results to return. - * @param - * @name sort - * @Ptype string - * @required false - * @valid downloads, created_at, updated_at, stars - * @default relevance - * @location query - * @Pdesc Method to sort the results. - * @param - * @name direction - * @Ptype string - * @required false - * @valid asc, desc - * @default desc - * @location query - * @Pdesc Direction to list search results. - * @response - * @status 200 - * @Rtype application/json - * @Rdesc Same format as listing packages, additionally paginated at 30 items. - */ -app.get("/api/:packType/search", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": { - let ret = await package_handler.getPackagesSearch( - { - sort: query.sort(req), - page: query.page(req), - direction: query.dir(req), - query: query.query(req), - }, - database - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // Since we know this is a paginated endpoint we must handle that here - res.append("Link", ret.link); - res.append("Query-Total", ret.total); - res.append("Query-Limit", ret.limit); - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - } - case "themes": { - const params = { - sort: query.sort(req), - page: query.page(req), - direction: query.dir(req), - query: query.query(req), - }; - - let ret = await theme_handler.getThemesSearch(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // Since we know this is a paginated endpoint we must handle that here - res.append("Link", ret.link); - res.append("Query-Total", ret.total); - res.append("Query-Limit", ret.limit); - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - } - default: { - next(); - break; - } - } -}); - -app.options("/api/:packType/search", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } -}); - -/** - * @web - * @ignore - * @path /api/packages/:packageName - * @desc Show package details. - * @method GET - * @auth false - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name packageName - * @location path - * @Ptype string - * @Pdesc The name of the package to return details for. URL escaped. - * @required true - * @param - * @name engine - * @location query - * @Ptype string - * @Pdesc Only show packages compatible with this Atom version. Must be valid SemVer. - * @required false - * @response - * @status 200 - * @Rtype application/json - * @Rdesc Returns package details and versions for a single package. - */ -app.get("/api/:packType/:packageName", genericLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - // We can use the same handler here because the logic of the return - // Will be identical no matter what type of package it is. - const params = { - engine: query.engine(req.query.engine), - name: query.packageName(req), - }; - - let ret = await package_handler.getPackagesDetails(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - default: - next(); - break; - } -}); - -/** - * @web - * @ignore - * @path /api/packages/:packageName - * @method DELETE - * @auth true - * @desc Delete a package. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name packageName - * @location path - * @Ptype string - * @Pdesc The name of the package to delete. - * @required true - * @param - * @name Authorization - * @location header - * @Ptype string - * @Pdesc A valid Atom.io token, in the 'Authorization' Header. - * @required true - * @response - * @status 204 - * @Rtype application/json - * @Rdesc Successfully deleted package. Returns No Content. - * @response - * @status 400 - * @Rtype application/json - * @Rdesc Repository is inaccessible. - * @Rexample { "message": "Respository is inaccessible." } - * @response - * @status 401 - * @Rtype application/json - * @Rdesc Unauthorized. - */ -app.delete("/api/:packType/:packageName", authLimit, async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - auth: query.auth(req), - packageName: query.packageName(req), - }; - - let ret = await package_handler.deletePackagesName( - params, - database, - auth, - vcs - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // We know on success we should just return a statuscode - res.status(204).send(); - logger.httpLog(req, res); - - break; - default: - next(); - break; - } -}); - -app.options( - "/api/:packType/:packageName", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "DELETE, GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/star - * @method POST - * @auth true - * @desc Star a package. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name packageName - * @location path - * @Ptype string - * @Pdesc The name of the package to star. - * @required true - * @param - * @name Authorization - * @location header - * @Ptype string - * @Pdesc A valid Atom.io token, in the 'Authorization' Header - * @required true - * @response - * @status 200 - * @Rtype application/json - * @Rdesc Returns the package that was stared. - */ -app.post( - "/api/:packType/:packageName/star", - authLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - auth: query.auth(req), - packageName: query.packageName(req), - }; - - let ret = await package_handler.postPackagesStar( - params, - database, - auth - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/star - * @method DELETE - * @auth true - * @desc Unstar a package, requires authentication. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location header - * @Ptype string - * @name Authentication - * @required true - * @Pdesc Atom Token, in the Header Authentication Item - * @param - * @location path - * @Ptype string - * @name packageName - * @required true - * @Pdesc The package name to unstar. - * @response - * @status 201 - * @Rdesc An empty response to convey successfully unstaring a package. - */ -app.delete( - "/api/:packType/:packageName/star", - authLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - auth: query.auth(req), - packageName: query.packageName(req), - }; - - let ret = await package_handler.deletePackagesStar( - params, - database, - auth - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // On success we just return status code - res.status(201).send(); - logger.httpLog(req, res); - - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/star", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "DELETE, POST", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/stargazers - * @method GET - * @desc List the users that have starred a package. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location path - * @required true - * @name packageName - * @Pdesc The package name to check for users stars. - * @response - * @status 200 - * @Rdesc A list of user Objects. - * @Rexample [ { "login": "aperson" }, { "login": "anotherperson" } ] - */ -app.get( - "/api/:packType/:packageName/stargazers", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - packageName: query.packageName(req), - }; - let ret = await package_handler.getPackagesStargazers(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/stargazers", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/versions - * @auth true - * @method POST - * @desc Creates a new package version. If `rename` is not `true`, the `name` field in `package.json` _must_ match the current package name. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location path - * @name packageName - * @required true - * @Pdesc The Package to modify. - * @param - * @location query - * @name rename - * @required false - * @Pdesc Boolean indicating whether this version contains a new name for the package. - * @param - * @location header - * @name auth - * @required true - * @Pdesc A valid Atom.io API token, to authenticate with Github. - * @response - * @status 201 - * @Rdesc Successfully created. Returns created version. - * @response - * @status 400 - * @Rdesc Git tag not found / Repository inaccessible / package.json invalid. - */ -app.post( - "/api/:packType/:packageName/versions", - authLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - rename: query.rename(req), - auth: query.auth(req), - packageName: query.packageName(req), - }; - - let ret = await package_handler.postPackagesVersion( - params, - database, - auth, - vcs - ); - - if (!ret.ok) { - if (ret.type === "detailed") { - await common_handler.handleDetailedError(req, res, ret.content); - return; - } else { - await common_handler.handleError(req, res, ret.content); - return; - } - } - - res.status(201).json(ret.content); - - // Return to user before webhook call, so user doesn't wait on it - await webhook.alertPublishVersion(ret.webhook.pack, ret.webhook.user); - // Now to call for feature detection - let features = await vcs.featureDetection( - ret.featureDetection.user, - ret.featureDetection.ownerRepo, - ret.featureDetection.service - ); - - if (!features.ok) { - logger.generic(3, features); - return; - } - - // Then we know we don't need to apply any special features for a standard - // package, so we will check that early - if (features.content.standard) { - return; - } - - let featureApply = await database.applyFeatures( - features.content, - ret.webhook.pack.name, - ret.webhook.pack.version - ); - - if (!featureApply.ok) { - logger.generic(3, featureApply); - return; - } - - // Otherwise we have completed successfully. - // We could log this, but will just return - return; - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/versions", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "POST", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/versions/:versionName - * @method GET - * @auth false - * @desc Returns `package.json` with `dist` key added for tarball download. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location path - * @name packageName - * @required true - * @Pdesc The package name we want to access - * @param - * @location path - * @name versionName - * @required true - * @Pdesc The Version we want to access. - * @response - * @status 200 - * @Rdesc The `package.json` modified as explainged in the endpoint description. - */ -app.get( - "/api/:packType/:packageName/versions/:versionName", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - packageName: query.packageName(req), - versionName: query.engine(req.params.versionName), - }; - - let ret = await package_handler.getPackagesVersion(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/versions/:versionName - * @method DELETE - * @auth true - * @desc Deletes a package version. Note once a version is deleted, that same version should not be reused again. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location header - * @name Authentication - * @required true - * @Pdesc The Authentication header containing a valid Atom Token - * @param - * @location path - * @name packageName - * @required true - * @Pdesc The package name to check for the version to delete. - * @param - * @location path - * @name versionName - * @required true - * @Pdesc The Package Version to actually delete. - * @response - * @status 204 - * @Rdesc Indicates a successful deletion. - */ -app.delete( - "/api/:packType/:packageName/versions/:versionName", - authLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - auth: query.auth(req), - packageName: query.packageName(req), - versionName: query.engine(req.params.versionName), - }; - - let ret = await package_handler.deletePackageVersion( - params, - database, - auth, - vcs - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // This is, on success, and empty return - res.status(204).send(); - logger.httpLog(req, res); - - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/versions/:versionName", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "GET, DELETE", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/versions/:versionName/tarball - * @method GET - * @auth false - * @desc Previously undocumented endpoint. Seems to allow for installation of a package. This is not currently implemented. - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @location path - * @name packageName - * @required true - * @Pdesc The package we want to download. - * @param - * @location path - * @name versionName - * @required true - * @Pdesc The package version we want to download. - * @response - * @status 200 - * @Rdesc The tarball data for the user to then be able to install. - */ -app.get( - "/api/:packType/:packageName/versions/:versionName/tarball", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - const params = { - packageName: query.packageName(req), - versionName: query.engine(req.params.versionName), - }; - - let ret = await package_handler.getPackagesVersionTarball( - params, - database - ); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // We know this endpoint, if successful will redirect, so that must be handled here - res.redirect(ret.content); - logger.httpLog(req, res); - - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/versions/:versionName/tarball", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/packages/:packageName/versions/:versionName/events/uninstall - * @desc Previously undocumented endpoint. BETA: Decreases the packages download count, by one. Indicating an uninstall. - * v1.0.2 - Now has no effect. Being deprecated, but presents no change to end users. - * @method POST - * @auth true - * @param - * @name packType - * @location path - * @Ptype string - * @required true - * @Pdesc The Package Type you want to request. - * @valid packages, themes - * @param - * @name packageName - * @location path - * @required true - * @Pdesc The name of the package to modify. - * @param - * @name versionName - * @location path - * @required true - * @Pdesc This value is within the original spec. But has no use in its current implementation. - * @param - * @name auth - * @location header - * @required true - * @Pdesc Valid Atom.io token. - * @response - * @status 200 - * @Rdesc Returns JSON ok: true - */ -app.post( - "/api/:packType/:packageName/versions/:versionName/events/uninstall", - authLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - /** - Used when a package is uninstalled, decreases the download count by 1. - Originally an undocumented endpoint. - The decision to return a '201' is based on how other POST endpoints return, - during a successful event. - This endpoint has now been deprecated, as it serves no useful features, - and on further examination may have been intended as a way to collect - data on users, which is not something we implement. - * Deprecated since v1.0.2 - * see: https://github.com/atom/apm/blob/master/src/uninstall.coffee - * While decoupling HTTP handling from logic, the function has been removed - entirely: https://github.com/pulsar-edit/package-backend/pull/171 - */ - res.status(200).json({ ok: true }); - logger.httpLog(req, res); - break; - default: - next(); - break; - } - } -); - -app.options( - "/api/:packType/:packageName/versions/:versionName/events/uninstall", - genericLimit, - async (req, res, next) => { - switch (req.params.packType) { - case "packages": - case "themes": - res.header({ - Allow: "POST", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); - break; - default: - next(); - break; - } - } -); - -/** - * @web - * @ignore - * @path /api/users/:login/stars - * @method GET - * @auth false - * @desc List a user's starred packages. - * @param - * @name login - * @Ptype string - * @required true - * @Pdesc The username of who to list their stars. - * @response - * @status 200 - * @Rdesc Return value is similar to GET /api/packages - * @response - * @status 404 - * @Rdesc If the login does not exist, a 404 is returned. - */ -app.get("/api/users/:login/stars", genericLimit, async (req, res) => { - const params = { - login: query.login(req), - }; - - let ret = await user_handler.getLoginStars(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); -}); - -app.options("/api/users/:login/stars", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/users - * @method GET - * @desc Display details of the currently authenticated user. - * This endpoint is undocumented and technically doesn't exist. - * This is a strange endpoint that only exists on the Web version of the upstream - * API. Having no equivalent on the backend. This is an inferred implementation. - * @auth true - * @param - * @name auth - * @location header - * @Ptype string - * @required true - * @Pdesc Authorization Header of valid User Account Token. - * @response - * @status 200 - * @Rdesc The return Details of the User Account. - * @Rtype application/json - */ -app.get("/api/users", authLimit, async (req, res) => { - res.header("Access-Control-Allow-Methods", "GET"); - res.header( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, Access-Control-Allow-Credentials" - ); - res.header("Access-Control-Allow-Origin", "https://web.pulsar-edit.dev"); - res.header("Access-Control-Allow-Credentials", true); - - const params = { - auth: query.auth(req), - }; - - let ret = await user_handler.getAuthUser(params, database, auth); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - // TODO: This was set within the function previously, needs to be determined if this is needed - res.set({ "Access-Control-Allow-Credentials": true }); - - res.status(200).json(ret.content); - logger.httpLog(req, res); -}); - -app.options("/api/users", async (req, res) => { - res.header({ - Allow: "GET", - "Access-Control-Allow-Methods": "GET", - "Access-Control-Allow-Headers": - "Content-Type, Authorization, Access-Control-Allow-Credentials", - "Access-Control-Allow-Origin": "https://web.pulsar-edit.dev", - "Access-Control-Allow-Credentials": true, - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/users/:login - * @method GET - * @desc Display the details of any user, as well as the packages they have published. - * @auth false - * @param - * @name login - * @location path - * @Ptype string - * @required true - * @Pdesc The User of which to collect the details of. - * @response - * @status 200 - * @Rdesc The returned details of a specific user, along with the packages they have published. - * @Rtype application/json - */ -app.get("/api/users/:login", genericLimit, async (req, res) => { - const params = { - login: query.login(req), - }; - - let ret = await user_handler.getUser(params, database); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); -}); - -app.options("/api/users/:login", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/stars - * @method GET - * @desc List the authenticated user's starred packages. - * @auth true - * @param - * @name auth - * @location header - * @Ptype string - * @required true - * @Pdesc Authorization Header of valid Atom.io Token. - * @response - * @status 200 - * @Rdesc Return value similar to GET /api/packages, an array of package objects. - * @Rtype application/json - */ -app.get("/api/stars", authLimit, async (req, res) => { - const params = { - auth: query.auth(req), - }; - - let ret = await star_handler.getStars(params, database, auth); - - if (!ret.ok) { - await common_handler.handleError(req, res, ret.content); - return; - } - - res.status(200).json(ret.content); - logger.httpLog(req, res); -}); - -app.options("/api/stars", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -/** - * @web - * @ignore - * @path /api/updates - * @method GET - * @desc List Atom Updates. - * @response - * @status 200 - * @Rtype application/json - * @Rdesc Atom update feed, following the format expected by Squirrel. - */ -app.get("/api/updates", genericLimit, async (req, res) => { - let ret = await update_handler.getUpdates(); - - if (!ret.ok) { - await common_handler.notSupported(req, res); - return; - } - - // TODO: There is no else until this endpoint is implemented. -}); - -app.options("/api/updates", genericLimit, async (req, res) => { - res.header({ - Allow: "GET", - "X-Content-Type-Options": "nosniff", - }); - res.sendStatus(204); -}); - -app.use(async (err, req, res, next) => { - // Having this as the last route, will handle all other unknown routes. - // Ensure to leave this at the very last position to handle properly. - // We can also check for any unhandled errors passed down the endpoint chain - - if (err) { - console.error( - `An error was encountered handling the request: ${err.toString()}` - ); - await common_handler.serverError(req, res, err); - return; - } - - common_handler.siteWideNotFound(req, res); -}); - -module.exports = app; diff --git a/src/models/sso.js b/src/models/sso.js new file mode 100644 index 00000000..6f05b1e0 --- /dev/null +++ b/src/models/sso.js @@ -0,0 +1,160 @@ +const { performance } = require("node:perf_hooks"); + +const validEnums = [ + "not_found", + "server_error", + "not_supported", + "unauthorized", + "bad_repo", + "package_exists" +]; + +const enumDetails = { + "not_found": { + code: 404, + message: "Not Found" + }, + "server_error": { + code: 500, + message: "Application Error" + }, + "not_supported": { + code: 501, + message: "While under development this feature is not supported." + }, + "unauthorized": { + code: 401, + message: "Unauthorized" + }, + "bad_repo": { + code: 400, + message: "That repo does not exist, or is inaccessible" + }, + "package_exists": { + code: 409, + message: "A Package by that name already exists." + } +}; + +module.exports = +class SSO { + constructor() { + this.ok = false; + this.content = {}; + this.short = ""; + this.message = ""; + this.safeContent = false; + this.successStatusCode = 200; + this.calls = {}; + } + + isOk() { + this.ok = true; + return this; + } + + notOk() { + this.ok = false; + return this; + } + + addContent(content, safe) { + if (typeof safe === "boolean") { + this.safeContent = safe; + } + + this.content = content; + return this; + } + + addCalls(id, content) { + this.calls[id] = { + content: content, + time: performance.now() + }; + return this; + } + + addShort(enumValue) { + if ( + this.short?.length <= 0 && + typeof enumValue === "string" && + validEnums.includes(enumValue) + ) { + // Only assign short once + this.short = enumValue; + } + return this; + } + + addMessage(msg) { + this.message = msg; + return this; + } + + addGoodStatus(status) { + this.successStatusCode = status; + return this; + } + + handleReturnHTTP(req, res, context) { + if (!this.ok) { + this.handleError(req, res, context); + return; + } + + this.handleSuccess(req, res, context); + return; + } + + handleError(req, res, context) { + console.log(this); + + let shortToUse, msgToUse, codeToUse; + + if (typeof this.short === "string" && this.short.length > 0) { + // Use the short given to us during the build stage + shortToUse = this.short; + + } else if (typeof this.content?.short === "string" && this.content.short.length > 0) { + // Use the short that's bubbled up from other calls + shortToUse = this.content.short; + + } else { + // Use the default short + shortToUse = "server_error"; + } + + // Now that we have our short, we must determine the text of our message. + msgToUse = enumDetails[shortToUse]?.message ?? `Server Error: From ${shortToUse}`; + + codeToUse = enumDetails[shortToUse]?.code ?? 500; + + if (typeof this.message === "string" && this.message.length > 0) { + msgToUse += `: ${this.message}`; + } + // TODO We should make use of our `calls` here. + // Not only for logging more details. + // But we also could use this to get more information to return. Such as + // providing helpful error logs and such. + + res.status(codeToUse).json({ + message: msgToUse + }); + + // TODO Log our error too! + context.logger.httpLog(req, res); + return; + } + + handleSuccess(req, res, context) { + + if (typeof this.content === "boolean" && this.content === false) { + res.status(this.successStatusCode).send(); + } else { + res.status(this.successStatusCode).json(this.content); + } + context.logger.httpLog(req, res); + return; + } +} diff --git a/src/models/ssoHTML.js b/src/models/ssoHTML.js new file mode 100644 index 00000000..e8c4343b --- /dev/null +++ b/src/models/ssoHTML.js @@ -0,0 +1,13 @@ +const SSO = require("./sso.js"); + +module.exports = +class SSOHTML extends SSO { + constructor() { + super(); + } + + handleSuccess(req, res, context) { + res.send(this.content); + context.logger.httpLog(req, res); + } +} diff --git a/src/models/ssoPaginate.js b/src/models/ssoPaginate.js new file mode 100644 index 00000000..ef3caa47 --- /dev/null +++ b/src/models/ssoPaginate.js @@ -0,0 +1,54 @@ +const SSO = require("./sso.js"); + +module.exports = +class SSOPaginate extends SSO { + constructor() { + super(); + + this.link = ""; + this.total = 0; + this.limit = 0; + } + + buildLink(url, currentPage, params) { + let paramString = ""; + + for (let param in params) { + // We manually assign the page query so we will skip + if (param === "page") { + continue; + } + if (param === "query") { + // Since we know we want to keep search queries safe strings + const safeQuery = encodeURIComponent( + params[param].replace(/[<>"':;\\/]+/g, "") + ); + paramString += `&${param}=${safeQuery}`; + } else { + paramString += `&${param}=${params[param]}`; + } + } + + let linkString = ""; + + linkString += `<${url}?page=${currentPage}${paramString}>; rel="self", `; + linkString += `<${url}?page=${this.total}${paramString}>; rel="last"`; + + if (currentPage !== this.total) { + linkString += `, <${url}?page=${parseInt(currentPage) + 1}${paramString}>; rel="next"`; + } + + this.link = linkString; + } + + handleSuccess(req, res, context) { + + res.append("Link", this.link); + res.append("Query-Total", this.total); + res.append("Query-Limit", this.limit); + + res.status(this.successStatusCode).json(this.content); + context.logger.httpLog(req, res); + return; + } +} diff --git a/src/models/ssoRedirect.js b/src/models/ssoRedirect.js new file mode 100644 index 00000000..7e7714f5 --- /dev/null +++ b/src/models/ssoRedirect.js @@ -0,0 +1,13 @@ +const SSO = require("./sso.js"); + +module.exports = +class SSORedirect extends SSO { + constructor() { + super(); + } + + handleSuccess(req, res, context) { + res.redirect(this.content); + context.logger.httpLog(req, res); + } +} diff --git a/src/server.js b/src/server.js index 263771c9..8cbd667e 100644 --- a/src/server.js +++ b/src/server.js @@ -4,7 +4,7 @@ * to listen on. As well as handling a graceful shutdown of the server. */ -const app = require("./main.js"); +const app = require("./setupEndpoints.js"); const { port } = require("./config.js").getConfig(); const logger = require("./logger.js"); const database = require("./database.js"); diff --git a/src/setupEndpoints.js b/src/setupEndpoints.js new file mode 100644 index 00000000..c9a43a47 --- /dev/null +++ b/src/setupEndpoints.js @@ -0,0 +1,148 @@ +const express = require("express"); +const rateLimit = require("express-rate-limit"); +const { MemoryStore } = require("express-rate-limit"); + +const endpoints = require("./controllers/endpoints.js"); +const context = require("./context.js"); + +const app = express(); + +// Define our Basic Rate Limiters +const genericLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + // Limit each IP per window, 0 disables rate limit + max: process.env.PULSAR_STATUS === "dev" ? 0 : context.config.RATE_LIMIT_GENERIC, + standardHeaders: true, // Return rate limit info in headers + legacyHeaders: true, // Legacy rate limit info in headers + store: new MemoryStore(), // use default memory store + message: "Too many requests, please try again later.", // Message once limit is reached + statusCode: 429, // HTTP Status code once limit is reached + handler: (request, response, next, options) => { + response.status(options.statusCode).json({ message: options.message }); + context.logger.httpLog(request, response); + } +}); + +const authLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + // Limit each IP per window, 0 disables rate limit. + max: process.env.PULSAR_STATUS === "dev" ? 0 : context.config.RATE_LIMIT_AUTH, + standardHeaders: true, // Return rate limit info on headers + legacyHeaders: true, // Legacy rate limit info in headers + store: new MemoryStore(), // use default memory store + message: "Too many requests, please try again later.", // message once limit is reached + statusCode: 429, // HTTP Status code once limit is reached. + handler: (request, response, next, options) => { + response.status(options.statusCode).json({ message: options.message }); + context.logger.httpLog(request, response); + } +}); + +// Set express defaults + +app.set("trust proxy", true); + +app.use("/swagger-ui", express.static("docs/swagger")); + +const endpointHandler = async function(node, req, res) { + let params = {}; + + for (const param in node.params) { + params[param] = node.params[param](context, req); + } + + if (typeof node.preLogic === "function") { + await node.preLogic(req, res, context); + } + + let obj; + + if (node.endpoint.endpointKind === "raw") { + await node.logic(req, res, context); + // If it's a raw endpoint, they must handle all other steps manually + return; + + } else { + obj = await node.logic(params, context); + } + + if (typeof node.postLogic === "function") { + await node.postLogic(req, res, context); + } + + obj.addGoodStatus(node.endpoint.successStatus); + + obj.handleReturnHTTP(req, res, context); + + if (typeof node.postReturnHTTP === "function") { + await node.postReturnHTTP(req, res, context, obj); + } + + return; +}; + +// Setup all endpoints + +const pathOptions = []; + +for (const node of endpoints) { + for (const path of node.endpoint.paths) { + + let limiter = genericLimit; + + if (node.endpoint.rateLimit === "auth") { + limiter = authLimit; + } else if (node.endpoint.rateLimit === "generic") { + limiter = genericLimit; + } + + if (!pathOptions.includes(path)) { + app.options(path, genericLimit, async (req, res) => { + res.header(node.endpoint.options); + res.sendStatus(204); + return; + }); + + pathOptions.push(path); + } + + switch(node.endpoint.method) { + case "GET": + app.get(path, limiter, async (req, res) => { + await endpointHandler(node, req, res); + }); + break; + case "POST": + app.post(path, limiter, async (req, res) => { + await endpointHandler(node, req, res); + }); + break; + case "DELETE": + app.delete(path, limiter, async (req, res) => { + await endpointHandler(node, req, res); + }); + break; + default: + console.log(`Unsupported method: ${node.endpoint.method} for ${path}`); + } + + } +} + +app.use(async (err, req, res, next) => { + // Having this as the last route, will handle all other unkown routes. + // Ensure we leave this at the very last position to handle properly. + // We can also check for any unhandled errors passed down the endpoint chain + + if (err) { + console.error( + `An error was encountered handling the request: ${err.toString()}` + ); + await context.common_handler.serverError(req, res, err); + return; + } + + context.common_handler.siteWideNotFound(res, res); +}); + +module.exports = app; diff --git a/src/storage.js b/src/storage.js index 16a0bff2..01016eff 100644 --- a/src/storage.js +++ b/src/storage.js @@ -8,7 +8,7 @@ const { Storage } = require("@google-cloud/storage"); const logger = require("./logger.js"); const { CacheObject } = require("./cache.js"); -const ServerStatus = require("./ServerStatusObject.js"); +const sso = require("./models/sso.js"); const { GCLOUD_STORAGE_BUCKET, GOOGLE_APPLICATION_CREDENTIALS } = require("./config.js").getConfig(); @@ -26,6 +26,34 @@ function setupGCS() { }); } +async function getGcpContent(file) { + if ( + GOOGLE_APPLICATION_CREDENTIALS === "nofile" || + process.env.PULSAR_STATUS === "dev" + ) { + // This catches the instance when tests are being run, without access + // or good reason to reach to 3rd party servers. + // We will instead return local data + // Setting GOOGLE_APPLICATION_CREDENTIALS to "nofile" will be the recommended + // method for running locally. + const fs = require("fs"); + const path = require("path"); + + const contents = fs.readFileSync(path.resolve(`./docs/resources/${file}`), { encoding: "utf8" }); + return contents; + } else { + // This is a production request + gcsStorage ??= setupGCS(); + + const contents = await gcsStorage + .bucket(GCLOUD_STORAGE_BUCKET) + .file(file) + .download(); + + return contents; + } +} + /** * @async * @function getBanList @@ -36,40 +64,19 @@ function setupGCS() { * @returns {Array} Parsed JSON Array of all Banned Packages. */ async function getBanList() { - gcsStorage ??= setupGCS(); const getNew = async function () { - if ( - GOOGLE_APPLICATION_CREDENTIALS === "nofile" || - process.env.PULSAR_STATUS === "dev" - ) { - // This catches the instance when tests are being run, without access - // or good reason to reach to 3rd party servers. - // We will log a warning, and return preset test data. - // Setting GOOGLE_APPLICATION_CREDENTIALS to "nofile" will be the recommended - // method for running locally. - // TODO: Have this read the data from the ban list locally - console.log("storage.js.getBanList() Returning Development Set of Data."); - let list = ["slothoki", "slot-pulsa", "slot-dana", "hoki-slot"]; - cachedBanlist = new CacheObject(list); - cachedBanlist.last_validate = Date.now(); - return new ServerStatus().isOk().setContent(cachedBanlist.data).build(); - } try { - let contents = await gcsStorage - .bucket(GCLOUD_STORAGE_BUCKET) - .file("name_ban_list.json") - .download(); + const contents = await getGcpContent("name_ban_list.json"); + cachedBanlist = new CacheObject(JSON.parse(contents)); cachedBanlist.last_validate = Date.now(); - return new ServerStatus().isOk().setContent(cachedBanlist.data).build(); + return new sso().isOk().addContent(cachedBanlist.data); } catch (err) { - return new ServerStatus() - .notOk() - .setShort("Server Error") - .setContent(err) - .build(); + return new sso().notOk() + .addShort("server_error") + .addCalls("getGcpContent", err); } }; @@ -80,7 +87,7 @@ async function getBanList() { if (!cachedBanlist.Expired) { logger.generic(5, "Ban List Cache NOT Expired."); - return new ServerStatus().isOk().setContent(cachedBanlist.data).build(); + return new sso().isOk().addContent(cachedBanlist.data); } logger.generic(5, "Ban List Cache IS Expired."); @@ -95,46 +102,20 @@ async function getBanList() { * @returns {Array} Parsed JSON Array of all Featured Packages. */ async function getFeaturedPackages() { - gcsStorage ??= setupGCS(); const getNew = async function () { - if ( - GOOGLE_APPLICATION_CREDENTIALS === "nofile" || - process.env.PULSAR_STATUS === "dev" - ) { - // This catches the instance when tests are being run, without access - // or good reason to reach to 3rd party servers. - // We will log a warning, and return preset test data. - // TODO: Have this read the featured packages locally - console.log( - "storage.js.getFeaturedPackages() Returning Development Set of Data." - ); - let list = ["hydrogen", "atom-clock", "hey-pane"]; - cachedFeaturedlist = new CacheObject(list); - cachedFeaturedlist.last_validate = Date.now(); - return new ServerStatus() - .isOk() - .setContent(cachedFeaturedlist.data) - .build(); - } try { - let contents = await gcsStorage - .bucket(GCLOUD_STORAGE_BUCKET) - .file("featured_packages.json") - .download(); + const contents = await getGcpContent("featured_packages.json"); + cachedFeaturedlist = new CacheObject(JSON.parse(contents)); cachedFeaturedlist.last_validate = Date.now(); - return new ServerStatus() - .isOk() - .setContent(cachedFeaturedlist.data) - .build(); + return new sso().isOk() + .addContent(cachedFeaturedlist.data); } catch (err) { - return new ServerStatus() - .notOk() - .setShort("Server Error") - .setContent(err) - .build(); + return new sso().notOk() + .addShort("server_error") + .addCalls("getGcpContent", err); } }; @@ -145,10 +126,8 @@ async function getFeaturedPackages() { if (!cachedFeaturedlist.Expired) { logger.generic(5, "Ban List Cache NOT Expired."); - return new ServerStatus() - .isOk() - .setContent(cachedFeaturedlist.data) - .build(); + return new sso().isOk() + .addContent(cachedFeaturedlist.data); } logger.generic(5, "Ban List Cache IS Expired."); @@ -162,40 +141,19 @@ async function getFeaturedPackages() { * @returns {Array} JSON Parsed Array of Featured Theme Names. */ async function getFeaturedThemes() { - gcsStorage ??= setupGCS(); const getNew = async function () { - if ( - GOOGLE_APPLICATION_CREDENTIALS === "nofile" || - process.env.PULSAR_STATUS === "dev" - ) { - // This catches the instance when tests are being run, without access - // or good reason to reach to 3rd party servers. - // We will log a warning, and return preset test data. - // TODO: Have this read the featured themes locally - console.log( - "storage.js.getFeaturedThemes() Returning Development Set of Data." - ); - let list = ["atom-material-ui", "atom-material-syntax"]; - cachedThemelist = new CacheObject(list); - cachedThemelist.last_validate = Date.now(); - return new ServerStatus().isOk().setContent(cachedThemelist.data).build(); - } try { - let contents = await gcsStorage - .bucket(GCLOUD_STORAGE_BUCKET) - .file("featured_themes.json") - .download(); + const contents = await getGcpContent("featured_themes.json"); + cachedThemelist = new CacheObject(JSON.parse(contents)); cachedThemelist.last_validate = Date.now(); - return new ServerStatus().isOk().setContent(cachedThemelist.data).build(); + return new sso().isOk().addContent(cachedThemelist.data); } catch (err) { - return new ServerStatus() - .notOk() - .setShort("Server Error") - .setContent(err) - .build(); + return new sso().notOk() + .addShort("server_error") + .addCalls("getGcpContent", err); } }; @@ -206,7 +164,7 @@ async function getFeaturedThemes() { if (!cachedThemelist.Expired) { logger.generic(5, "Theme List Cache NOT Expired."); - return new ServerStatus().isOk().setContent(cachedThemelist.data).build(); + return new sso().isOk().addContent(cachedThemelist.data); } logger.generic(5, "Theme List Cache IS Expired."); diff --git a/src/utils.js b/src/utils.js index 5c2eb011..cc57fdf9 100644 --- a/src/utils.js +++ b/src/utils.js @@ -52,7 +52,7 @@ async function constructPackageObjectFull(pack) { for (const v of vers) { retVer[v.semver] = v.meta; retVer[v.semver].license = v.license; - retVer[v.semver].engine = v.engine; + retVer[v.semver].engines = v.engines; retVer[v.semver].dist = { tarball: `${server_url}/api/packages/${pack.name}/versions/${v.semver}/tarball`, }; @@ -121,6 +121,9 @@ async function constructPackageObjectShort(pack) { newPack.badges.push({ title: "Made for Pulsar!", type: "success" }); } + // Remove keys that aren't intended to exist in a Package Object Short + delete newPack.versions; + return newPack; }; @@ -182,7 +185,7 @@ async function constructPackageObjectJSON(pack) { } newPack.dist ??= {}; newPack.dist.tarball = `${server_url}/api/packages/${v.meta.name}/versions/${v.semver}/tarball`; - newPack.engines = v.engine; + newPack.engines = v.engines; logger.generic(6, "Single Package Object JSON finished without Error"); return newPack; }; diff --git a/test/debug_utils.unit.test.js b/test/debug_utils.unit.test.js deleted file mode 100644 index aad676fa..00000000 --- a/test/debug_utils.unit.test.js +++ /dev/null @@ -1,31 +0,0 @@ -const debug_utils = require("../src/debug_utils.js"); - -describe("Test lengths Returned by different Variables", () => { - const objectCases = [ - [ - { - value: "Hello World", - }, - 22, - ], - [ - { - boolean: true, - }, - 4, - ], - [ - { - obj: { - boolean: false, - value: "H", - }, - }, - 6, - ], - ]; - - test.each(objectCases)("Given %o Returns %p", (arg, expectedResult) => { - expect(debug_utils.roughSizeOfObject(arg)).toBe(expectedResult); - }); -}); diff --git a/test/global.setup.jest.js b/test/global.setup.jest.js deleted file mode 100644 index 10ef4f63..00000000 --- a/test/global.setup.jest.js +++ /dev/null @@ -1,40 +0,0 @@ -// Add `expect().toMatchSchema()` to Jest, for matching against Joi Schemas - -const jestJoi = require("jest-joi"); - -expect.extend(jestJoi.matchers); - -// Add our custom extensions -expect.extend({ - // `expect().toBeArray()` - toBeArray(value) { - if (Array.isArray(value)) { - return { - pass: true, - message: () => "", - }; - } else { - return { - pass: false, - message: () => - `Expected Array but received: ${this.utils.printReceived(value)}`, - }; - } - }, - // `expect().toHaveHTTPCode()` - toHaveHTTPCode(req, want) { - // Type coercion here because the statusCode in the request object could be set as a string. - if (req.statusCode == want) { - return { - pass: true, - message: () => "", - }; - } else { - return { - pass: false, - message: () => - `Expected HTTP Status Code: ${want} but got ${req.statusCode}`, - }; - } - }, -}); diff --git a/test/post.packages.handler.integration.test.js b/test/post.packages.handler.integration.test.js deleted file mode 100644 index ac1e406e..00000000 --- a/test/post.packages.handler.integration.test.js +++ /dev/null @@ -1,303 +0,0 @@ -const request = require("supertest"); -const app = require("../src/main.js"); - -// Mock any webhooks that would be sent -const webhook = require("../src/webhook.js"); - -jest.mock("../src/webhook.js", () => { - return { - alertPublishPackage: jest.fn(), - alertPublishVersion: jest.fn(), - }; -}); - -const { authMock } = require("./httpMock.helper.jest.js"); - -let tmpMock; - -describe("Post /api/packages", () => { - afterEach(() => { - tmpMock.mockClear(); - }); - - test("Fails with 'Bad Auth' when bad token is passed.", async () => { - tmpMock = authMock({ - ok: false, - short: "Bad Auth", - content: "Bad Auth Mock Return for Dev User", - }); - - const res = await request(app) - .post("/api/packages") - .query({ repository: "pulsar-edit/langauge-css" }) - .set("Authorization", "invalid"); - expect(res.body.message).toEqual(msg.badAuth); - expect(res).toHaveHTTPCode(401); - }); - - test("Fails with 'badRepoJSON' when no repo is passed.", async () => { - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 9999, - node_id: "post-pkg-publish-test-user-node-id", - username: "post-pkg-publish-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const res = await request(app) - .post("/api/packages") - .query({ repository: "" }) - .set("Authorization", "valid-token"); - expect(res.body.message).toEqual(msg.badRepoJSON); - expect(res).toHaveHTTPCode(400); - }); - - test("Fails with 'badRepoJSON' when bad repo is passed.", async () => { - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 9999, - node_id: "post-pkg-publish-test-user-node-id", - username: "post-pkg-publish-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const res = await request(app) - .post("/api/packages") - .query({ repository: "not-exist" }) - .set("Authorization", "valid-token"); - expect(res.body.message).toEqual(msg.badRepoJSON); - expect(res).toHaveHTTPCode(400); - }); - test("Fails with 'badRepoJSON' when Repo with a space is passed", async () => { - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 9999, - node_id: "post-pkg-publish-test-user-node-id", - username: "post-pkg-publish-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const res = await request(app) - .post("/api/packages") - .query({ repository: "pulsar-edit/language CSS" }) - .set("Authorization", "valid-token"); - expect(res.body.message).toEqual(msg.badRepoJSON); - expect(res).toHaveHTTPCode(400); - }); - - test("Fails with 'publishPackageExists' when existing package is passed", async () => { - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 9999, - node_id: "post-pkg-publish-test-user-node-id", - username: "post-pkg-publish-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const res = await request(app) - .post("/api/packages") - .query({ repository: "pulsar-edit/language-pon" }) - .set("Authorization", "valid-token"); - expect(res.body.message).toEqual(msg.publishPackageExists); - expect(res).toHaveHTTPCode(409); - }); - - test.todo("Tests that actually modify data"); -}); - -describe("POST /api/packages/:packageName/versions", () => { - beforeEach(() => { - tmpMock = authMock({ - ok: false, - short: "Bad Auth", - content: "Bad Auth Mock Return for Dev user", - }); - }); - - afterEach(() => { - tmpMock.mockClear(); - }); - - test("Returns Bad Auth appropriately with Bad Package", async () => { - const res = await request(app).post( - "/api/packages/language-golang/versions" - ); - expect(res).toHaveHTTPCode(401); - expect(res.body.message).toEqual(msg.badAuth); - }); - - test.todo("Write all tests on this endpoint"); -}); - -describe("POST /api/packages/:packageName/star", () => { - afterEach(() => { - tmpMock.mockClear(); - }); - - test("Returns 401 with No Auth", async () => { - tmpMock = authMock({ - ok: false, - short: "Bad Auth", - content: "Bad Auth Mock Return for Dev User", - }); - - const res = await request(app).post("/api/packages/language-gfm/star"); - expect(res).toHaveHTTPCode(401); - }); - - test("Returns Bad Auth Msg with No Auth", async () => { - const res = await request(app).post("/api/packages/langauge-gfm/star"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns 401 with Bad Auth", async () => { - tmpMock = authMock({ - ok: false, - short: "Bad Auth", - content: "Bad Auth Mock Return for Dev User", - }); - - const res = await request(app) - .post("/api/packages/language-gfm/star") - .set("Authorization", "invalid"); - expect(res).toHaveHTTPCode(401); - }); - test("Returns Bad Auth Msg with Bad Auth", async () => { - tmpMock = authMock({ - ok: false, - short: "Bad Auth", - content: "Bad Auth Mock Return for Dev User", - }); - - const res = await request(app) - .post("/api/packages/language-gfm/star") - .set("Authorization", "invalid"); - expect(res.body.message).toEqual(msg.badAuth); - }); - test("Returns not found with bad package", async () => { - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - }, - }); - - const res = await request(app) - .post("/api/packages/no-exist/star") - .set("Authorization", "valid-token"); - - expect(res).toHaveHTTPCode(404); - expect(res.body.message).toEqual(msg.notFound); - }); - test("Returns proper data on Success", async () => { - const prev = await request(app).get("/api/packages/language-gfm"); - - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 999, - node_id: "post-star-test-user-node-id", - username: "post-star-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const res = await request(app) - .post("/api/packages/language-gfm/star") - .set("Authorization", "valid-token"); - - tmpMock.mockClear(); - - tmpMock = authMock({ - ok: true, - content: { - token: "valid-token", - id: 999, - node_id: "post-star-test-user-node-id", - username: "post-star-test-user", - avatar: "https://roadtonowhere.com", - }, - }); - - const dup = await request(app) - .post("/api/packages/language-gfm/star") - .set("Authorization", "valid-token"); - // We are preforming multiple checks in the single check, - // because we want to test a star action when the package is already starred. - - // DESCRIBE: Returns Success Status Code - expect(res).toHaveHTTPCode(200); - // DESCRIBE: Returns same Package - expect(res.body.name).toEqual("language-gfm"); - // DESCRIBE: Properly Increases Star Count - expect(parseInt(res.body.stargazers_count, 10)).toEqual( - parseInt(prev.body.stargazers_count, 10) + 1 - ); - // DESCRIBE: A duplicate Request Returns Success Status Code - expect(dup).toHaveHTTPCode(200); - // DESCRIBE: A duplicate Request keeps the star, but does not increase the count - expect(parseInt(res.body.stargazers_count, 10)).toEqual( - parseInt(dup.body.stargazers_count, 10) - ); - }); -}); - -describe("POST /api/packages/:packageName/versions/:versionName/events/uninstall", () => { - // This endpoint is now being deprecated, so we will remove tests - // for handling any kind of actual functionality. - // Instead ensuring this returns as success to users are unaffected. - test.todo( - "This endpoint is deprecated, once it's fully removed, these tests should be too." - ); - - test("Returns 200 with Valid Package, Bad Version", async () => { - const res = await request(app) - .post("/api/packages/language-css/versions/1.0.0/events/uninstall") - .set("Authorization", "valid-token"); - expect(res).toHaveHTTPCode(200); - // Please note on Atom.io this would result in a 404. But the Pulsar Backend intentionally ignores the `version` - // of the query. This is due to changes in the database structure. - }); - test("Returns Json {ok: true } with Valid Package, Bad Version", async () => { - const res = await request(app) - .post("/api/packages/language-css/versions/1.0.0/events/uninstall") - .set("Authorization", "valid-token"); - expect(res.body.ok).toBeTruthy(); - // Please note on Atom.io this would result in a 404. But the Pulsar Backend intentionally ignores the `version` - // of the query. This is due to changes in the database structure. - }); - test("Returns 200 on Success", async () => { - const res = await request(app) - .post("/api/packages/language-css/versions/0.45.7/events/uninstall") - .set("Authorization", "valid-token"); - expect(res).toHaveHTTPCode(200); - }); - test("Returns Json { ok: true } on Success", async () => { - const res = await request(app) - .post("/api/packages/language-css/versions/0.45.7/events/uninstall") - .set("Authorization", "valid-token"); - expect(res.body.ok).toBeTruthy(); - }); - test("After deprecating endpoint, ensure the endpoint has no effect", async () => { - const orig = await request(app).get("/api/packages/language-css"); - const res = await request(app) - .post("/api/packages/language-css/versions/0.45.7/events/uninstall") - .set("Authorization", "valid-token"); - const after = await request(app).get("/api/packages/language-css"); - expect(parseInt(orig.body.downloads, 10)).toEqual( - parseInt(after.body.downloads, 10) - ); - }); -}); diff --git a/test/root.handler.integration.test.js b/test/root.handler.integration.test.js deleted file mode 100644 index b971fe79..00000000 --- a/test/root.handler.integration.test.js +++ /dev/null @@ -1,22 +0,0 @@ -const request = require("supertest"); -const app = require("../src/main.js"); - -describe("Get /", () => { - test("Should respond with an HTML document noting the server version", async () => { - const res = await request(app) - .get("/") - .expect("Content-Type", "text/html; charset=utf-8"); - - expect(res.text).toEqual( - expect.stringContaining("Server is up and running Version") - ); - }); - test("Should Return valid status code", async () => { - const res = await request(app).get("/"); - expect(res).toHaveHTTPCode(200); - }); - test("Should 404 on invalid method", async () => { - const res = await request(app).patch("/"); - expect(res).toHaveHTTPCode(404); - }); -}); diff --git a/test/updates.handler.integration.test.js b/test/updates.handler.integration.test.js deleted file mode 100644 index 93196e86..00000000 --- a/test/updates.handler.integration.test.js +++ /dev/null @@ -1,14 +0,0 @@ -const request = require("supertest"); -const app = require("../src/main.js"); - -describe("GET /api/updates", () => { - test.todo("/api/updates currentlty returns Not Supported."); - test("Returns NotSupported Status Code.", async () => { - const res = await request(app).get("/api/updates"); - expect(res).toHaveHTTPCode(501); - }); - test("Returns NotSupported Message", async () => { - const res = await request(app).get("/api/updates"); - expect(res.body.message).toEqual(msg.notSupported); - }); -}); diff --git a/test/database/applyFeatures.test.js b/tests/database/applyFeatures.test.js similarity index 99% rename from test/database/applyFeatures.test.js rename to tests/database/applyFeatures.test.js index 65fc9b43..18545616 100644 --- a/test/database/applyFeatures.test.js +++ b/tests/database/applyFeatures.test.js @@ -16,7 +16,7 @@ describe("Exits properly", () => { expect(res.content).toBe( "Unable to find the pointer of this-name-doesn't-exist" ); - expect(res.short).toBe("Not Found"); + expect(res.short).toBe("not_found"); }); }); diff --git a/test/database.integration.test.js b/tests/database/database.test.js similarity index 95% rename from test/database.integration.test.js rename to tests/database/database.test.js index c93f7d67..4820b7c9 100644 --- a/test/database.integration.test.js +++ b/tests/database/database.test.js @@ -1,3 +1,6 @@ +// This file has been moved directly from the old testing method. +// Likely should be updated at one point + // This is our secondary integration test. // Due to the difficulty in testing some aspects as full integration tests, // namely tests for publishing and updating packages (due to the varried responses expected by github) @@ -6,8 +9,8 @@ // Or at the very least that if there is a failure within these, it will not result in // bad data being entered into the database in production. -let database = require("../src/database.js"); -let utils = require("../src/utils.js"); +let database = require("../../src/database.js"); +let utils = require("../../src/utils.js"); afterAll(async () => { await database.shutdownSQL(); @@ -39,7 +42,7 @@ describe("insertNewPackageName", () => { "notARepo-Reborn" ); expect(obj.ok).toBeFalsy(); - expect(obj.short).toEqual("Not Found"); + expect(obj.short).toEqual("not_found"); }); test("Should return Success for valid package", async () => { const obj = await database.insertNewPackageName( @@ -57,12 +60,12 @@ describe("getPackageByName", () => { test("Should return Server Error for Package that doesn't exist", async () => { const obj = await database.getPackageByName("language-golang"); expect(obj.ok).toBeFalsy(); - expect(obj.short).toEqual("Not Found"); + expect(obj.short).toEqual("not_found"); }); test("Should return Server Error for Package that doesn't exist, even with User", async () => { const obj = await database.getPackageByName("language-golang", true); expect(obj.ok).toBeFalsy(); - expect(obj.short).toEqual("Not Found"); + expect(obj.short).toEqual("not_found"); }); }); @@ -398,7 +401,7 @@ describe("Package Lifecycle Tests", () => { // === Can we get our now deleted package? const ghostPack = await database.getPackageByName(NEW_NAME); expect(ghostPack.ok).toBeFalsy(); - expect(ghostPack.short).toEqual("Not Found"); + expect(ghostPack.short).toEqual("not_found"); // === Is the name of the deleted package available? const deletedNameAvailable = await database.packageNameAvailability( @@ -412,7 +415,7 @@ describe("Package Lifecycle Tests", () => { // === Can we get our Non-Existant User? const noExistUser = await database.getUserByNodeID(user.userObj.node_id); expect(noExistUser.ok).toBeFalsy(); - expect(noExistUser.short).toEqual("Not Found"); + expect(noExistUser.short).toEqual("not_found"); // === Can we create our User? const createUser = await database.insertNewUser( @@ -469,10 +472,29 @@ describe("Package Lifecycle Tests", () => { expect(getFakeStars.content.length).toEqual(0); // === Can we star a package with our User? + // (After of course first creating the package to star) + await database.insertNewPackage({ + name: "language-css", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { latest: "1.0.0" }, + readme: "This is a readme!", + metadata: { name: "language-css" }, + versions: { + "1.0.0": { + dist: { tarball: "download-url", sha: "1234" }, + name: "language-css" + } + } + }); const starPack = await database.updateIncrementStar( getUserID.content, "language-css" ); + expect(starPack.ok).toBeTruthy(); expect(starPack.content).toEqual("Package Successfully Starred"); @@ -513,6 +535,9 @@ describe("Package Lifecycle Tests", () => { // === Can we remove our User? // TODO: Currently there is no way to delete a user account. // There is no supported endpoint for this, but is something that should be implemented. + + // Lets cleanup by deleting the package we made + await database.removePackageByName("language-css", true); }); }); diff --git a/test/database/extensionFilter.test.js b/tests/database/extensionFilter.test.js similarity index 100% rename from test/database/extensionFilter.test.js rename to tests/database/extensionFilter.test.js diff --git a/test/fixtures/git.createPackage_returns/valid_multi_version.js b/tests/database/fixtures/git.createPackage_returns/valid_multi_version.js similarity index 100% rename from test/fixtures/git.createPackage_returns/valid_multi_version.js rename to tests/database/fixtures/git.createPackage_returns/valid_multi_version.js diff --git a/test/fixtures/git.createPackage_returns/valid_one_version.js b/tests/database/fixtures/git.createPackage_returns/valid_one_version.js similarity index 100% rename from test/fixtures/git.createPackage_returns/valid_one_version.js rename to tests/database/fixtures/git.createPackage_returns/valid_one_version.js diff --git a/test/fixtures/lifetime/package-a.js b/tests/database/fixtures/lifetime/package-a.js similarity index 100% rename from test/fixtures/lifetime/package-a.js rename to tests/database/fixtures/lifetime/package-a.js diff --git a/test/fixtures/lifetime/user-a.js b/tests/database/fixtures/lifetime/user-a.js similarity index 100% rename from test/fixtures/lifetime/user-a.js rename to tests/database/fixtures/lifetime/user-a.js diff --git a/tests/helpers/global.setup.jest.js b/tests/helpers/global.setup.jest.js new file mode 100644 index 00000000..de08a48a --- /dev/null +++ b/tests/helpers/global.setup.jest.js @@ -0,0 +1,100 @@ +// Add `expect().toMatchSchema()` to Jest, for matching against Joi Schemas +const Joi = require("joi"); +global.Joi = Joi; +// We add Joi to the global context so that the `models/* .test` object doesn't need +// to worry about `require`ing the module. +const jestJoi = require("jest-joi"); + +expect.extend(jestJoi.matchers); + +// Add our custom extensions +expect.extend({ + // `expect().toBeArray()` + toBeArray(value) { + if (Array.isArray(value)) { + return { + pass: true, + message: () => "", + }; + } else { + return { + pass: false, + message: () => + `Expected Array but received: ${this.utils.printReceived(value)}`, + }; + } + }, + // `expect().toBeTypeof(typeof)` + toBeTypeof(actual, want) { + if (typeof actual === want) { + return { + pass: true, + message: () => "" + }; + } else { + return { + pass: false, + message: () => `Expected "${want}" but got "${typeof actual}"` + }; + } + }, + // `expect().toBeIncludedBy(ARRAY)` + toBeIncludedBy(actual, want) { + if (Array.isArray(want) && want.includes(actual)) { + return { + pass: true, + message: () => "" + }; + } else { + return { + pass: false, + message: () => `Expected ${want} to include ${actual}` + }; + } + }, + // `expect().toMatchEndpointSuccessObject(endpoint)` + toMatchEndpointSuccessObject(sso, endpoint) { + let done = false; + for (const response in endpoint.docs.responses) { + // We use `==` to facilitate type coercion + if (response == endpoint.endpoint.successStatus) { + let obj = endpoint.docs.responses[response].content["application/json"]; + + if (obj.startsWith("$")) { + obj = require(`../models/${obj.replace("$","")}.js`); + } + + expect(sso.content).toMatchSchema(obj.test); + done = true; + break; + } + } + if (done) { + return { + pass: true, message: () => "" + }; + } else { + return { + pass: false, + message: () => + `Unable to find ${endpoint.endpoint.successStatus}.` + }; + } + }, + // `expect().toHaveHTTPCode()` + toHaveHTTPCode(req, want) { + // Type coercion here because the statusCode in the request object could be set as a string. + if (req.statusCode == want) { + return { + pass: true, + message: () => "", + }; + } else { + return { + pass: false, + message: () => + `Expected HTTP Status Code: ${want} but got ${req.statusCode}`, + }; + } + }, +}); diff --git a/test/handlers.setup.jest.js b/tests/helpers/handlers.setup.jest.js similarity index 98% rename from test/handlers.setup.jest.js rename to tests/helpers/handlers.setup.jest.js index d2a164e4..f22eb9c5 100644 --- a/test/handlers.setup.jest.js +++ b/tests/helpers/handlers.setup.jest.js @@ -2,7 +2,7 @@ // This mainly means to properly set timeouts, and to ensure that required // env vars are set properly. -jest.setTimeout(3000000); +//jest.setTimeout(3000000); const dbUrl = process.env.DATABASE_URL; // this gives us something like postgres://test-user@localhost:5432/test-db diff --git a/test/httpMock.helper.jest.js b/tests/helpers/httpMock.helper.jest.js similarity index 93% rename from test/httpMock.helper.jest.js rename to tests/helpers/httpMock.helper.jest.js index 854c36b2..c5c1d022 100644 --- a/test/httpMock.helper.jest.js +++ b/tests/helpers/httpMock.helper.jest.js @@ -6,10 +6,10 @@ * And encoding data into `base64` as expected on the fly. */ -const Git = require("../src/vcs_providers/git.js"); +const Git = require("../../src/vcs_providers/git.js"); -const auth = require("../src/auth.js"); -const vcs = require("../src/vcs.js"); +const auth = require("../../src/auth.js"); +const vcs = require("../../src/vcs.js"); class HTTP { constructor(path) { diff --git a/tests/http/deletePackagesPackageName.test.js b/tests/http/deletePackagesPackageName.test.js new file mode 100644 index 00000000..18aa8197 --- /dev/null +++ b/tests/http/deletePackagesPackageName.test.js @@ -0,0 +1,118 @@ +const endpoint = require("../../src/controllers/deletePackagesPackageName.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("DELETE /api/packages/:packageName", () => { + test("Fails with bad auth", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: false, + short: "unauthorized", + content: "Bad Auth Mock Return" + }; + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("unauthorized"); + }); + test("Fails with not found with bad package", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 9999, + node_id: "dlt-pkg-test-user-node-id", + username: "dlt-pkg-test-user-node-id", + avatar: "https://roadtonowhere.com" + } + }; + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "no-exist" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + + test("Successfully deletes a package", async () => { + await database.insertNewPackage({ + name: "dlt-pkg-by-name-test", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.0.0" + }, + readme: "This is a readme!", + metadata: { name: "dlt-pkg-by-name-test" }, + versions: { + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "dlt-pkg-by-name-test" + } + } + }); + + let addUser = await database.insertNewUser( + "dlt-pkg-test-user-node-id", + "dlt-pkg-test-user-node-id", + "https://roadtonowhere.com" + ); + + expect(addUser.ok).toBe(true); + + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + // The user data must match whats in the db + id: addUser.content.id, + node_id: addUser.content.node_id, + username: addUser.content.username, + avatar: addUser.content.avatar + } + }; + }; + + localContext.vcs.ownership = () => { + return { + ok: true, + content: "admin" + }; + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "dlt-pkg-by-name-test" + }, localContext); + + expect(sso.ok).toBe(true); + expect(sso.content).toBe(false); + + let doesPackageStillExist = await database.getPackageByName("dlt-pkg-by-name-test"); + + expect(doesPackageStillExist.ok).toBe(false); + expect(doesPackageStillExist.short).toBe("not_found"); + + let isPackageNameAvailable = await database.packageNameAvailability("dlt-pkg-by-name-test"); + + expect(isPackageNameAvailable.ok).toBe(false); + expect(isPackageNameAvailable.short).toBe("not_found"); + expect(isPackageNameAvailable.content).toBe("dlt-pkg-by-name-test is not available to be used for a new package."); + }); +}); diff --git a/tests/http/deletePackagesPackageNameVersionsVersionName.test.js b/tests/http/deletePackagesPackageNameVersionsVersionName.test.js new file mode 100644 index 00000000..6ef270a8 --- /dev/null +++ b/tests/http/deletePackagesPackageNameVersionsVersionName.test.js @@ -0,0 +1,129 @@ +const endpoint = require("../../src/controllers/deletePackagesPackageNameVersionsVersionName.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("DELETE /api/packages/:packageName/versions/:versionName", () => { + test("Fails with bad auth", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: false, + short: "unauthorized", + content: "Bad Auth Mock Return" + }; + }; + + const sso = await endpoint.logic({ versionName: "" }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("unauthorized"); + }); + test("Fails with not found with bad package", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 9999, + node_id: "dlt-pkg-ver-user-node-id", + username: "dlt-pkg-ver-user-node-id", + avatar: "https://roadtonowhere.com" + } + }; + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "no-exist", + versionName: "1.0.0" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + test("Successfully deletes a package version", async () => { + await database.insertNewPackage({ + name: "dlt-pkg-ver-by-name-test", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.0.1" + }, + readme: "This is a readme!", + metadata: { name: "dlt-pkg-ver-by-name-test" }, + versions: { + "1.0.1": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "dlt-pkg-ver-by-name-test" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "dlt-pkg-ver-by-name-test" + } + } + }); + + let addUser = await database.insertNewUser( + "dlt-pkg-ver-test-user-node-id", + "dlt-pkg-ver-test-user-node-id", + "https://roadotonowhere.com" + ); + + expect(addUser.ok).toBe(true); + + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + // The user data must match whats in the db + id: addUser.content.id, + node_id: addUser.content.node_id, + username: addUser.content.username, + avatar: addUser.content.avatar + } + }; + }; + localContext.vcs.ownership = () => { + return { + ok: true, + content: "admin" + }; + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "dlt-pkg-ver-by-name-test", + versionName: "1.0.1" + }, localContext); + + expect(sso.ok).toBe(true); + expect(sso.content).toBe(false); + + let currentPackageData = await database.getPackageByName("dlt-pkg-ver-by-name-test"); + + expect(currentPackageData.ok).toBe(true); + + currentPackageData = await context.utils.constructPackageObjectFull(currentPackageData.content); + + expect(currentPackageData.name).toBe("dlt-pkg-ver-by-name-test"); + // Does it modify the latest package version + expect(currentPackageData.releases.latest).toBe("1.0.0"); + expect(currentPackageData.versions["1.0.0"]).toBeTruthy(); + expect(currentPackageData.versions["1.0.1"]).toBeFalsy(); + + // cleanup + await database.removePackageByName("dlt-pkg-ver-by-name-test", true); + }); +}); diff --git a/tests/http/getPackagesFeatured.test.js b/tests/http/getPackagesFeatured.test.js new file mode 100644 index 00000000..1131a872 --- /dev/null +++ b/tests/http/getPackagesFeatured.test.js @@ -0,0 +1,68 @@ +const endpoint = require("../../src/controllers/getPackagesFeatured.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Calls the correct function", async () => { + const localContext = context; + const spy = jest.spyOn(localContext.database, "getFeaturedPackages"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns not found with no packages present", async () => { + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + + test("Returns proper data on success", async () => { + const addPack = await database.insertNewPackage({ + // We know a currently featured package is 'x-terminal-reloaded' + name: "x-terminal-reloaded", + repository: { + url: "https://github.com/Spiker985/x-terminal-reloaded", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.1.0" + }, + readme: "This is a readme!", + metadata: { + name: "atom-material-ui" + }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "x-terminal-reloaded" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "x-terminal-reloaded" + } + } + }); + + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(1); + expect(sso.content[0].name).toBe("x-terminal-reloaded"); + expect(sso).toMatchEndpointSuccessObject(endpoint); + + await database.removePackageByName("x-terminal-reloaded", true); + }); +}); diff --git a/tests/http/getPackagesPackageName.test.js b/tests/http/getPackagesPackageName.test.js new file mode 100644 index 00000000..148c4ab4 --- /dev/null +++ b/tests/http/getPackagesPackageName.test.js @@ -0,0 +1,72 @@ +const endpoint = require("../../src/controllers/getPackagesPackageName.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Calls the correct function", async () => { + const localContext = context; + const spy = jest.spyOn(localContext.database, "getPackageByName"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns 'not_found' when package doesn't exist", async () => { + const sso = await endpoint.logic({ + engine: false, + packageName: "anything" + }, context); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + + test("Returns package on success", async () => { + await database.insertNewPackage({ + name: "get-package-test", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.1.0" + }, + readme: "This is a readme!", + metadata: { + name: "get-package-test" + }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "get-package-test" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "get-package-test" + } + } + }); + + const sso = await endpoint.logic({ + engine: false, + packageName: "get-package-test" + }, context); + + expect(sso.ok).toBe(true); + expect(sso.content.name).toBe("get-package-test"); + expect(sso).toMatchEndpointSuccessObject(endpoint); + await database.removePackageByName("get-package-test", true); + }); + + +}); diff --git a/tests/http/getRoot.test.js b/tests/http/getRoot.test.js new file mode 100644 index 00000000..c4d4cec7 --- /dev/null +++ b/tests/http/getRoot.test.js @@ -0,0 +1,28 @@ +const endpoint = require("../../src/controllers/getRoot.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Should respond with an HTML document", async () => { + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(true); + expect(sso.content).toEqual( + expect.stringContaining("Server is up and running Version") + ); + }); +}); + +describe("HTTP Handling works", () => { + test("Calls the right function", async () => { + const request = require("supertest"); + const app = require("../../src/setupEndpoints.js"); + + const spy = jest.spyOn(endpoint, "logic"); + + const res = await request(app).get("/"); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); +}); diff --git a/tests/http/getThemes.test.js b/tests/http/getThemes.test.js new file mode 100644 index 00000000..56b9dfb1 --- /dev/null +++ b/tests/http/getThemes.test.js @@ -0,0 +1,130 @@ +const endpoint = require("../../src/controllers/getThemes.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + + test("Calls the correct function", async () => { + const context = require("../../src/context.js"); + const localContext = context; + const spy = jest.spyOn(localContext.database, "getSortedPackages"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns empty array with no packages present", async () => { + // Testing for if no packages exist + const sso = await endpoint.logic( + { + page: "1", + sort: "downloads", + direction: "desc" + }, + context + ); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(0); + expect(sso.link).toBe( + `<${context.config.server_url}/api/themes?page=0&sort=downloads&direction=desc>;` + + ' rel="self", ' + + `<${context.config.server_url}/api/themes?page=0&sort=downloads&direction=desc>;` + + ' rel="last"' + ); + }); + test("Returns proper data on success", async () => { + const addName = await database.insertNewPackage({ + name: "test-package", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.1.0" + }, + readme: "This is a readme!", + metadata: { + name: "test-package", + theme: "syntax" + }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "test-package" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "test-package" + } + } + }); + + const sso = await endpoint.logic( + { + page: "1", + sort: "downloads", + direction: "desc" + }, + context + ); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(1); + expect(sso.content[0].name).toBe("test-package"); + expect(sso.link).toBe( + `<${context.config.server_url}/api/themes?page=1&sort=downloads&direction=desc>;` + + ' rel="self", ' + + `<${context.config.server_url}/api/themes?page=1&sort=downloads&direction=desc>;` + + ' rel="last"' + ); + expect(sso).toMatchEndpointSuccessObject(endpoint); + await database.removePackageByName("test-package", true); + }); + + test("Returns bad SSO on failure", async () => { + const localContext = context; + localContext.database = { + getSortedPackages: () => { return { ok: false, content: "Test Failure" }; } + }; + + const sso = await endpoint.logic( + { + page: "1", + sort: "downloads", + direction: "desc" + }, + localContext + ); + + expect(sso.ok).toBe(false); + expect(sso.content.content).toBe("Test Failure"); + }); +}); + +describe("HTTP Handling works", () => { + test("Calls the right function", async () => { + const request = require("supertest"); + const app = require("../../src/setupEndpoints.js"); + + const spy = jest.spyOn(endpoint, "logic"); + + const res = await request(app).get("/api/themes"); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); +}); diff --git a/tests/http/getThemesFeatured.test.js b/tests/http/getThemesFeatured.test.js new file mode 100644 index 00000000..bcce1672 --- /dev/null +++ b/tests/http/getThemesFeatured.test.js @@ -0,0 +1,68 @@ +const endpoint = require("../../src/controllers/getThemesFeatured.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Calls the correct function", async () => { + const localContext = context; + const spy = jest.spyOn(localContext.database, "getFeaturedThemes"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns not found with no packages present", async () => { + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + + test("Returns proper data on success", async () => { + const addPack = await database.insertNewPackage({ + // We know a currently featured package is 'atom-material-ui' + name: "atom-material-ui", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.1.0" + }, + readme: "This is a readme!", + metadata: { + name: "atom-material-ui", + theme: "ui" + }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "atom-material-ui" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "atom-material-ui" + } + } + }); + + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(1); + expect(sso.content[0].name).toBe("atom-material-ui"); + expect(sso).toMatchEndpointSuccessObject(endpoint); + await database.removePackageByName("atom-material-ui", true); + }); +}); diff --git a/tests/http/getThemesSearch.test.js b/tests/http/getThemesSearch.test.js new file mode 100644 index 00000000..2e2ca9fa --- /dev/null +++ b/tests/http/getThemesSearch.test.js @@ -0,0 +1,96 @@ +const endpoint = require("../../src/controllers/getThemesSearch.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Calls the correct function", async () => { + const localContext = context; + const spy = jest.spyOn(localContext.database, "simpleSearch"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns zero length array when not found", async () => { + const sso = await endpoint.logic({ + sort: "downloads", + page: "1", + direction: "desc", + query: "hello-world" + }, context); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(0); + }); + + test("Returns array on success", async () => { + const newPack = await database.insertNewPackage({ + name: "atom-material-syntax", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.1.0" + }, + readme: "This is a readme!", + metadata: { + name: "atom-material-syntax", + theme: "ui" + }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "atom-material-syntax" + }, + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "atom-material-syntax" + } + } + }); + + const sso = await endpoint.logic({ + sort: "downloads", + page: "1", + direction: "desc", + query: "atom-material" + }, context); + + expect(sso.ok).toBe(true); + expect(sso.content).toBeArray(); + expect(sso.content.length).toBe(1); + expect(sso.content[0].name).toBe("atom-material-syntax"); + expect(sso).toMatchEndpointSuccessObject(endpoint); + await database.removePackageByName("atom-material-syntax", true); + }); + + test("Returns error on db call failure", async () => { + // Moved to last position, since it modifies our shallow copied context + const localContext = context; + localContext.database = { + simpleSearch: () => { return { ok: false, content: "Test Error" } } + }; + + const sso = await endpoint.logic({ + sort: "downloads", + page: "1", + direction: "desc", + query: "hello-world" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.content).toBe("Test Error"); + }); +}); diff --git a/tests/http/getUpdates.test.js b/tests/http/getUpdates.test.js new file mode 100644 index 00000000..2cdeaa53 --- /dev/null +++ b/tests/http/getUpdates.test.js @@ -0,0 +1,26 @@ +const endpoint = require("../../src/controllers/getUpdates.js"); +const context = require("../../src/context.js"); + +describe("Behaves as expected", () => { + test("Returns properly", async () => { + const sso = await endpoint.logic({}, context); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("not_supported"); + }); +}); + +describe("HTTP Handling works", () => { + test("Calls the right function", async () => { + const request = require("supertest"); + const app = require("../../src/setupEndpoints.js"); + + const spy = jest.spyOn(endpoint, "logic"); + + const res = await request(app).get("/api/updates"); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); +}); diff --git a/tests/http/getUsers.test.js b/tests/http/getUsers.test.js new file mode 100644 index 00000000..ebf0bb1b --- /dev/null +++ b/tests/http/getUsers.test.js @@ -0,0 +1,106 @@ +const endpoint = require("../../src/controllers/getUsers.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); +const userObject = require("../models/userObjectPrivate.js"); + +describe("Behaves as expected", () => { + + test("Calls the correct function", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: false }; } + }; + + const spy = jest.spyOn(localContext.auth, "verifyAuth"); + + await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns bad SSO on failure", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: false, content: "A test fail" }; } + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.content).toBe("A test fail"); + }); + + test("Returns good SSO on success", async () => { + const testUser = userObject.example; + testUser.username = "test-user"; + + const localContext = context; + localContext.auth = { + verifyAuth: () => { + return { + ok: true, + content: testUser + }; + } + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(true); + expect(sso.content).toMatchObject(testUser); + expect(sso).toMatchEndpointSuccessObject(endpoint); + }); +}); + +describe("Extra functions behave", () => { + test("preLogic adds headers as needed", async () => { + const headerObj = {}; + const res = { + header: (name, val) => { headerObj[name] = val; } + }; + + await endpoint.preLogic({}, res, {}); + + const expected = { + "Access-Control-Allow-Methods": "GET", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Access-Control-Allow-Credentials", + "Access-Control-Allow-Origin": "https://web.pulsar-edit.dev", + "Access-Control-Allow-Credentials": true + }; + + expect(headerObj).toMatchObject(expected); + }); + + test("postLogic adds headers as needed", async () => { + let headerObj = {}; + + const res = { + set: (obj) => { headerObj = obj; } + }; + + await endpoint.postLogic({}, res, {}); + + const expected = { + "Access-Control-Allow-Credentials": true + }; + + expect(headerObj).toMatchObject(expected); + }); +}); + +describe("HTTP Handling works", () => { + test("Calls the right function", async () => { + const request = require("supertest"); + const app = require("../../src/setupEndpoints.js"); + + const spy = jest.spyOn(endpoint, "logic"); + + const res = await request(app).get("/api/users"); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); +}); diff --git a/tests/http/getUsersLogin.test.js b/tests/http/getUsersLogin.test.js new file mode 100644 index 00000000..19012752 --- /dev/null +++ b/tests/http/getUsersLogin.test.js @@ -0,0 +1,64 @@ +const endpoint = require("../../src/controllers/getUsersLogin.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); +const userObject = require("../models/userObjectPublic.js"); + +describe("Behaves as expected", () => { + + test("Calls the correct db function", async () => { + const localContext = context; + const spy = jest.spyOn(localContext.database, "getUserByName"); + + const res = await endpoint.logic({}, localContext); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); + + test("Returns bad SSO on failure", async () => { + const userName = "our-test-user"; + + const res = await endpoint.logic({ login: userName }, context); + + expect(res.ok).toBe(false); + expect(res.content.short).toBe("not_found"); + }); + + test("Returns Correct SSO on Success", async () => { + const userObj = userObject.example; + userObj.username = "our-test-user"; + + // First we add a new fake user + await database.insertNewUser(userObj.username, "id", userObj.avatar); + + const res = await endpoint.logic( + { + login: userObj.username + }, + context + ); + + expect(res.ok).toBe(true); + // First make sure the user object matches expectations broadly, then specifically + expect(res.content.username).toBe(userObj.username); + expect(res.content.avatar).toBe(userObj.avatar); + expect(res).toMatchEndpointSuccessObject(endpoint); + // TODO delete once there's a method to do so + }); +}); + +describe("HTTP Handling works", () => { + test("Calls the right function", async () => { + const request = require("supertest"); + const app = require("../../src/setupEndpoints.js"); + + const spy = jest.spyOn(endpoint, "logic"); + + const res = await request(app).get("/api/users/confused-Techie"); + + expect(spy).toBeCalledTimes(1); + + spy.mockClear(); + }); +}); diff --git a/test/login.handler.integration.test.js b/tests/http/login.test.js similarity index 86% rename from test/login.handler.integration.test.js rename to tests/http/login.test.js index 39094e94..7f0bc86e 100644 --- a/test/login.handler.integration.test.js +++ b/tests/http/login.test.js @@ -1,5 +1,5 @@ const request = require("supertest"); -const app = require("../src/main.js"); +const app = require("../../src/setupEndpoints.js"); describe("Get /api/login", () => { test("Returns proper Status Code", async () => { diff --git a/test/other.handler.integration.test.js b/tests/http/options.test.js similarity index 99% rename from test/other.handler.integration.test.js rename to tests/http/options.test.js index 538415aa..25fdcb95 100644 --- a/test/other.handler.integration.test.js +++ b/tests/http/options.test.js @@ -1,5 +1,5 @@ const request = require("supertest"); -const app = require("../src/main.js"); +const app = require("../../src/setupEndpoints.js"); describe("Ensure Options Method Returns as Expected", () => { const rateLimitHeaderCheck = (res) => { diff --git a/tests/http/postPackages.test.js b/tests/http/postPackages.test.js new file mode 100644 index 00000000..771b3e94 --- /dev/null +++ b/tests/http/postPackages.test.js @@ -0,0 +1,203 @@ +const endpoint = require("../../src/controllers/postPackages.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("POST /api/packages Behaves as expected", () => { + + test("Fails with 'unauthorized' when bad token is passed", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: false, + short: "unauthorized", + content: "Bad Auth Mock Return" + }; + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("unauthorized"); + }); + + test("Fails with 'bad repo' when no repo is passed", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 9999, + node_id: "post-pkg-publish-test-user-node-id", + username: "post-pkg-publish-test-user", + avatar: "https://roadtonowhere.com" + } + }; + }; + + const sso = await endpoint.logic({ + repository: "", + auth: "valid-token" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("bad_repo"); + }); + + test("Fails when a bad repo format is passed", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 9999, + node_id: "post-pkg-publish-test-user-node-id", + username: "post-pkg-publish-test-user", + avatar: "https://roadtonowhere.com" + } + }; + }; + + const sso = await endpoint.logic({ + repository: "bad-format", + auth: "valid-token" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("bad_repo"); + }); + + test("Fails if the package already exists", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 9999, + node_id: "post-pkg-publish-test-user-node-id", + username: "post-pkg-publish-test-user", + avatar: "https://roadtonowhere.com" + } + }; + }; + + await database.insertNewPackage({ + name: "post-packages-test-package", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { latest: "1.1.0" }, + readme: "This is a readme!", + metadata: { name: "post-packages-test-package" }, + versions: { + "1.1.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "post-packages-test-package" + } + } + }); + + const sso = await endpoint.logic({ + repository: "confused-Techie/post-packages-test-package", + auth: "valid-token" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("package_exists"); + + await database.removePackageByName("post-packages-test-package", true); + }); + + test("Successfully publishes a new package", async () => { + let addUser = await database.insertNewUser( + "post-pkg-test-user-node-id", + "post-pkg-test-user-node-id", + "https://roadtonowhere.com" + ); + + expect(addUser.ok).toBe(true); + + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + // The user data must match whats in the db + id: addUser.content.id, + node_id: addUser.content.node_id, + username: addUser.content.username, + avatar: addUser.content.avatar + } + }; + }; + localContext.vcs.ownership = () => { + return { + ok: true, + content: "admin" + }; + }; + localContext.vcs.newPackageData = () => { + return { + ok: true, + content: { + name: "post-pkg-test-pkg-name", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + downloads: 0, + stargazers_count: 0, + creation_method: "Test Package", + releases: { + latest: "1.0.0" + }, + readme: "This is a readme!", + metadata: { name: "post-pkg-test-pkg-name" }, + versions: { + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "post-pkg-test-pkg-name" + } + } + } + }; + }; + + const sso = await endpoint.logic({ + repository: "confused-Techie/post-pkg-test-pkg-name", + auth: "valid-token" + }, localContext); + + expect(sso.ok).toBe(true); + expect(sso.content.name).toBe("post-pkg-test-pkg-name"); + expect(sso.content.releases.latest).toBe("1.0.0"); + + // Can we get the package by a specific version + let packByVer = await database.getPackageVersionByNameAndVersion( + "post-pkg-test-pkg-name", + "1.0.0" + ); + + expect(packByVer.ok).toBe(true); + + packByVer = await context.utils.constructPackageObjectJSON(packByVer.content); + + expect(packByVer.name).toBe("post-pkg-test-pkg-name"); + expect(packByVer.dist.tarball).toContain("/api/packages/post-pkg-test-pkg-name/versions/1.0.0"); + + // Cleanup + await database.removePackageByName("post-pkg-test-pkg-name", true); + }); + +}); diff --git a/tests/http/postPackagesPackageNameStar.test.js b/tests/http/postPackagesPackageNameStar.test.js new file mode 100644 index 00000000..6401cdc6 --- /dev/null +++ b/tests/http/postPackagesPackageNameStar.test.js @@ -0,0 +1,100 @@ +const endpoint = require("../../src/controllers/postPackagesPackageNameStar.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("POST /api/packages/:packageName/star", () => { + test("Fails with bad auth, with no auth", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: false, + short: "unauthorized", + content: "Bad Auth Mock Return" + }; + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("unauthorized"); + }); + + test("Fails with not found with bad package", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + id: 99999, + node_id: "post-pkg-star-test-user-node-id", + username: "post-pkg-star-test-user-node-id", + avatar: "https://roadtonowhere.com" + } + }; + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "no-exist" + }, localContext); + + expect(sso.ok).toBe(false); + expect(sso.content.short).toBe("not_found"); + }); + + test("Returns package and updates star count on success", async () => { + await database.insertNewPackage({ + name: "post-packages-star-test", + repository: { + url: "https://github.com/confused-Techie/package-backend", + type: "git" + }, + creation_method: "Test Package", + releases: { + latest: "1.0.0" + }, + readme: "This is a readme!", + metadata: { name: "post-packages-star-test" }, + versions: { + "1.0.0": { + dist: { + tarball: "download-url", + sha: "1234" + }, + name: "post-packages-star-test" + } + } + }); + + let addUser = await database.insertNewUser("post-pkg-star-test-user-node-id", "post-pkg-star-test-user-node-id", "https://roadtonowhere.com"); + + expect(addUser.ok).toBe(true); + + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: true, + content: { + token: "valid-token", + // The user data, specifically the ID must match for starring to work + id: addUser.content.id, + node_id: addUser.content.node_id, + username: addUser.content.username, + avatar: addUser.content.avatar + } + } + }; + + const sso = await endpoint.logic({ + auth: "valid-token", + packageName: "post-packages-star-test" + }, localContext); + + expect(sso.ok).toBe(true); + expect(sso.content.name).toBe("post-packages-star-test"); + expect(sso.content.stargazers_count).toBe("1"); + expect(sso).toMatchEndpointSuccessObject(endpoint); + await database.removePackageByName("post-packages-star-test", true); + }); +}); diff --git a/tests/http/postPackagesPackageNameVersions.test.js b/tests/http/postPackagesPackageNameVersions.test.js new file mode 100644 index 00000000..6b0624ec --- /dev/null +++ b/tests/http/postPackagesPackageNameVersions.test.js @@ -0,0 +1,24 @@ +const endpoint = require("../../src/controllers/postPackagesPackageNameVersions.js"); +const database = require("../../src/database.js"); +const context = require("../../src/context.js"); + +describe("POST /api/packages/:packageName/versions", () => { + test("Fails with bad auth if given a bad auth", async () => { + const localContext = context; + localContext.auth.verifyAuth = () => { + return { + ok: false, + short: "unauthorized", + context: "Bad Auth Mock Return" + }; + }; + + const sso = await endpoint.logic({}, localContext); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("unauthorized"); + }); + + // This is where the original tests ended here + test.todo("Write the tests that are now possible"); +}); diff --git a/test/stars.handler.integration.test.js b/tests/http/stars.test.js similarity index 81% rename from test/stars.handler.integration.test.js rename to tests/http/stars.test.js index 344b72e0..19b45e42 100644 --- a/test/stars.handler.integration.test.js +++ b/tests/http/stars.test.js @@ -1,7 +1,7 @@ const request = require("supertest"); -const app = require("../src/main.js"); +const app = require("../../src/setupEndpoints.js"); -const { authMock } = require("./httpMock.helper.jest.js"); +const { authMock } = require("../helpers/httpMock.helper.jest.js"); let tmpMock; @@ -9,7 +9,7 @@ describe("GET /api/stars", () => { test("Returns Unauthenticated Status Code for Invalid User", async () => { tmpMock = authMock({ ok: false, - short: "Bad Auth", + short: "unauthorized", content: "Bad Auth Mock Return for Dev User", }); @@ -17,7 +17,7 @@ describe("GET /api/stars", () => { .get("/api/stars") .set("Authorization", "invalid"); expect(res).toHaveHTTPCode(401); - expect(res.body.message).toEqual(msg.badAuth); + expect(res.body.message).toEqual("Unauthorized: Please update your token if you haven't done so recently."); tmpMock.mockClear(); }); @@ -44,7 +44,7 @@ describe("GET /api/stars", () => { tmpMock.mockClear(); }); - test("Valid User with Stars Returns 200 Status Code", async () => { + test.skip("Valid User with Stars Returns 200 Status Code", async () => { tmpMock = authMock({ ok: true, content: { diff --git a/tests/models/message.js b/tests/models/message.js new file mode 100644 index 00000000..eba46891 --- /dev/null +++ b/tests/models/message.js @@ -0,0 +1,21 @@ +module.exports = { + schema: { + description: "A generic object that could contain status information or error messages.", + type: "object", + required: [ + "message" + ], + properties: { + message: { + type: "string" + } + } + }, + example: { + message: "This is some message content." + }, + test: + Joi.object({ + message: Joi.string().required() + }) +}; diff --git a/tests/models/packageObjectFull.js b/tests/models/packageObjectFull.js new file mode 100644 index 00000000..24a6b4f0 --- /dev/null +++ b/tests/models/packageObjectFull.js @@ -0,0 +1,128 @@ +module.exports = { + schema: { + description: "A 'Package Object Full' of a package on the PPR.", + type: "object", + required: [ + "name", "readme", "metadata", "releases", "versions", + "repository", "creation_method", "downloads", "stargazers_count", "badges" + ], + properties: { + name: { type: "string" }, + readme: { type: "string" }, + metadata: { type: "object" }, + releases: { type: "object" }, + versions: { type: "object" }, + repository: { type: "object" }, + creation_method: { type: "string" }, + downloads: { type: "string" }, + stargazers_count: { type: "string" }, + badges: { type: "array" } + } + }, + example: { + // This is nearly the full return of `language-powershell-revised` + name: "language-powershell-revised", + readme: "This is the full content of a readme file!", + metadata: { + // The metadata field is the `package.json` of the most recent version + // With the `dist` object added + dist: { + sha: "604a047247ded9df50e7325345405c93871868e5", + tarball: "https://api.github.com/repos/confused-Techie/language-powershell-revised/tarball/refs/tags/v1.0.0" + }, + name: "language-powershell-revised", + engines: { + atom: ">=1.0.0 <2.0.0" + }, + license: "MIT", + version: "1.0.0", + keywords: [], + // This may be a repository object + repository: "https://github.com/confused-Techie/language-powershell-revised", + description: "Updated, revised PowerShell Syntax Highlighting Support in Pulsar." + }, + releases: { + latest: "1.0.0" + }, + versions: { + "1.0.0": { + // This is the `package.json` of every version + // With a `dist` key added + dist: { + tarball: "https://api.pulsar-edit.dev/api/packages/language-powershell-revised/versions/1.0.0/tarball" + }, + name: "language-powershell-revised", + engines: { + atom: ">=1.0.0 <2.0.0" + }, + license: "MIT", + version: "1.0.0", + keywords: [], + repository: "https://github.com/confsued-Techie/language-powershell-revised", + description: "Updated, revised PowerShell Syntax Highlighting Support in Pulsar" + } + }, + repository: { + // This is the repo object for the VCS Service + url: "https://github.com/confsued-Techie/langauge-powershell-revised", + type: "git" + }, + // This can be either `User Made Package` or `Migrated Package` + creation_method: "User Made Package", + // Note how some fields here are strings not numbers + downloads: "54", + stargazers_count: "0", + badges: [ + // Some badges are baked in, some are applied at render time. + { + title: "Made for Pulsar!", + type: "success" + } + ] + }, + test: + Joi.object({ + name: Joi.string().required(), + readme: Joi.string().required(), + metadata: Joi.object().required(), + releases: Joi.object({ + latest: Joi.string().required() + }).required(), + versions: Joi.object().required(), + repository: Joi.object({ + url: Joi.string().required(), + type: Joi.string().valid( + "git", + "bit", + "sfr", + "lab", + "berg", + "unknown", + "na" + ).required() + }).required(), + creation_method: Joi.string().valid( + "User Made Package", + "Migrated from Atom.io", + "Test Package" // Should only be used during tests + ).required(), + downloads: Joi.string().pattern(/^[0-9]+$/).required(), + stargazers_count: Joi.string().pattern(/^[0-9]+$/).required(), + badges: Joi.array().items( + Joi.object({ + title: Joi.string().valid( + "Outdated", + "Made for Pulsar!", + "Broken", + "Archived", + "Deprecated" + ).required(), + type: Joi.string().valid( + "warn", "info", "success" + ).required(), + text: Joi.string(), + link: Joi.string() + }) + ).required() + }).required() +}; diff --git a/tests/models/packageObjectFullArray.js b/tests/models/packageObjectFullArray.js new file mode 100644 index 00000000..6e397930 --- /dev/null +++ b/tests/models/packageObjectFullArray.js @@ -0,0 +1,12 @@ +module.exports = { + schema: { + + }, + example: [ + require("./packageObjectFull.js").example + ], + test: + Joi.array().items( + require("./packageObjectFull.js").test + ).required() +}; diff --git a/tests/models/packageObjectShort.js b/tests/models/packageObjectShort.js new file mode 100644 index 00000000..b39f3cd8 --- /dev/null +++ b/tests/models/packageObjectShort.js @@ -0,0 +1,131 @@ +module.exports = { + schema: { + description: "A 'Package Object Short' of a package on the PPR.", + type: "object", + required: [ + "name", "readme", "metadata", "repository", "downloads", "stargazers_count", + "releases", "badges" + ], + properties: { + name: { type: "string" }, + readme: { type: "string" }, + metadata: { type: "object" }, + repository: { type: "object" }, + creation_method: { type: "string" }, + downloads: { type: "string" }, + stargazers_count: { type: "string" }, + releases: { type: "object" }, + badges: { type: "array" } + } + }, + example: { + // Example taken from `platformio-ide-terminal` + name: "platformio-ide-terminal", + readme: "This is the full content of a readme file!", + metadata: { + main: "./lib/plaformio-ide-terminal", + name: "platformio-ide-terminal", + // This could be an author object + author: "Jeremy Ebneyamin", + engines: { + atom: ">=1.12.2 <2.0.0" + }, + license: "MIT", + version: "2.10.1", + homepage: "https://atom.io/packages/platformio=ide-terminal", + keywords: [ + "PlatformIO", + "terminal-plus", + "terminal" + ], + repository: "https://github.com/platformio/platformio-iatom-ide-terminal", + description: "A terminal package for Atom, complete with themes, API and more for PlatformIO IDE. Fork of terminal-plus.", + contributors: [ + { + url: "http://platformio.org", + name: "Ivan Kravets", + email: "me@kravets.com" + } + ], + dependencies: { + "term.js": "https://github.com/jeremyramin/term.js/tarball/master", + underscore: "^1.8.3", + "atom-psace-pen-views": "^2.2.0", + "node-pty-prebuilt-multiarch": "^0.9.0" + }, + activationHooks: [ + "core:loaded-shell-encironmnet" + ], + consumedServices: { + "status-bar": { + versions: { + "^1.0.0": "consumeStatusBar" + } + } + }, + providedServices: { + runInTerminal: { + versions: { + "0.14.5": "provideRunInTerminal" + }, + description: "Deprecated API for PlatformIO IDE 1.0" + } + } + }, + repository: { + url: "https://github.com/platformio/platformio-atom-ide-terminal", + type: "git" + }, + creation_method: "User Made Package", + downloads: "16997915", + stargazers_count: "1114", + releases: { + latest: "2.10.1" + }, + badges: [] + }, + test: + Joi.object({ + name: Joi.string().required(), + readme: Joi.string().required(), + metadata: Joi.object().required(), + releases: Joi.object({ + latest: Joi.string().required() + }).required(), + repository: Joi.object({ + url: Joi.string().required(), + type: Joi.string().valid( + "git", + "bit", + "sfr", + "lab", + "berg", + "unknown", + "na" + ).required() + }).required(), + creation_method: Joi.string().valid( + "User Made Package", + "Migrated from Atom.io", + "Test Package" // Should only be used during tests + ).required(), + downloads: Joi.string().pattern(/^[0-9]+$/).required(), + stargazers_count: Joi.string().pattern(/^[0-9]+$/).required(), + badges:Joi.array().items( + Joi.object({ + title: Joi.string().valid( + "Outdated", + "Made for Pulsar!", + "Broken", + "Archived", + "Deprecated" + ).required(), + type: Joi.string().valid( + "warn", "info", "success" + ).required(), + text: Joi.string(), + link: Joi.string() + }) + ).required() + }).required() +}; diff --git a/tests/models/packageObjectShortArray.js b/tests/models/packageObjectShortArray.js new file mode 100644 index 00000000..bb27a389 --- /dev/null +++ b/tests/models/packageObjectShortArray.js @@ -0,0 +1,12 @@ +module.exports = { + schema: { + + }, + example: [ + require("./packageObjectShort.js").example + ], + test: + Joi.array().items( + require("./packageObjectShort.js").test + ).required() +}; diff --git a/tests/models/userObjectPrivate.js b/tests/models/userObjectPrivate.js new file mode 100644 index 00000000..744b77b2 --- /dev/null +++ b/tests/models/userObjectPrivate.js @@ -0,0 +1,51 @@ +module.exports = { + schema: { + description: "Privately returned information of users on Pulsar.", + type: "object", + required: [ + "username", "avatar", "data", "created_at", "packages" + ], + properties: { + username: { + type: "string" + }, + avatar: { + type: "string" + }, + data: { + type: "object" + }, + node_id: { + type: "string" + }, + token: { + type: "string" + }, + created_at: { + type: "string" + }, + packages: { + type: "array" + } + } + }, + example: { + username: "confused-Techie", + avatar: "https://avatar.url", + data: {}, + node_id: "users-node-id", + token: "user-api-token", + created_at: "2023-09-16T00:58:36.755Z", + packages: [] + }, + test: + Joi.object({ + username: Joi.string().required(), + avatar: Joi.string().required(), + data: Joi.object().required(), + node_id: Joi.string().required(), + token: Joi.string().required(), + created_at: Joi.string().required(), + packages: Joi.array().required() + }) +}; diff --git a/tests/models/userObjectPublic.js b/tests/models/userObjectPublic.js new file mode 100644 index 00000000..1b0f97a3 --- /dev/null +++ b/tests/models/userObjectPublic.js @@ -0,0 +1,41 @@ +module.exports = { + schema: { + description: "Publicaly returned information of users on Pulsar.", + type: "object", + required: [ + "username", "avatar", "data", "created_at", "packages" + ], + properties: { + username: { + type: "string" + }, + avatar: { + type: "string" + }, + data: { + type: "object" + }, + created_at: { + type: "string" + }, + packages: { + type: "array" + } + } + }, + example: { + username: "confused-Techie", + avatar: "https://avatar.url", + data: {}, + created_at: "2023-09-16T00:58:36.755Z", + packages: [] + }, + test: + Joi.object({ + username: Joi.string().required(), + avatar: Joi.string().required(), + data: Joi.object().required(), + created_at: Joi.string().required(), + packages: Joi.array().required() + }) +}; diff --git a/test/PackageObject.unit.test.js b/tests/unit/PackageObject.test.js similarity index 95% rename from test/PackageObject.unit.test.js rename to tests/unit/PackageObject.test.js index 60b77a1b..3ec1f1d0 100644 --- a/test/PackageObject.unit.test.js +++ b/tests/unit/PackageObject.test.js @@ -1,4 +1,4 @@ -const PackageObject = require("../src/PackageObject.js"); +const PackageObject = require("../../src/PackageObject.js"); describe("Building Objects with PackageObject Return as Expected", () => { test("Formal Usage", () => { diff --git a/test/ServerStatusObject.unit.test.js b/tests/unit/ServerStatusObject.test.js similarity index 94% rename from test/ServerStatusObject.unit.test.js rename to tests/unit/ServerStatusObject.test.js index f409dff7..fd538a0a 100644 --- a/test/ServerStatusObject.unit.test.js +++ b/tests/unit/ServerStatusObject.test.js @@ -1,4 +1,4 @@ -const ServerStatus = require("../src/ServerStatusObject.js"); +const ServerStatus = require("../../src/ServerStatusObject.js"); const Joi = require("joi"); describe("Building Objects with ServerStatus Return as Expected", () => { diff --git a/test/cache.unit.test.js b/tests/unit/cache.test.js similarity index 97% rename from test/cache.unit.test.js rename to tests/unit/cache.test.js index ce65b543..ae425739 100644 --- a/test/cache.unit.test.js +++ b/tests/unit/cache.test.js @@ -1,4 +1,4 @@ -const cache = require("../src/cache.js"); +const cache = require("../../src/cache.js"); const Joi = require("joi"); test("Cache Creates Object As Expected", async () => { diff --git a/test/config.unit.test.js b/tests/unit/config.test.js similarity index 97% rename from test/config.unit.test.js rename to tests/unit/config.test.js index daa6ad39..a979b607 100644 --- a/test/config.unit.test.js +++ b/tests/unit/config.test.js @@ -1,4 +1,4 @@ -const config = require("../src/config.js"); +const config = require("../../src/config.js"); const Joi = require("joi"); describe("Config Returns all Expected Values", () => { diff --git a/tests/unit/controllers/deletePackagesPackageName.test.js b/tests/unit/controllers/deletePackagesPackageName.test.js new file mode 100644 index 00000000..9acac063 --- /dev/null +++ b/tests/unit/controllers/deletePackagesPackageName.test.js @@ -0,0 +1,18 @@ +const endpoint = require("../../../src/controllers/deletePackagesPackageName.js"); + +describe("Has features expected", () => { + test("endpoint features", () => { + const expected = { + method: "DELETE", + paths: [ "/api/packages/:packageName", "/api/themes/:packageName" ], + rateLimit: "auth", + successStatus: 204 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); diff --git a/tests/unit/controllers/deletePackagesPackageNameStar.js b/tests/unit/controllers/deletePackagesPackageNameStar.js new file mode 100644 index 00000000..7a84a507 --- /dev/null +++ b/tests/unit/controllers/deletePackagesPackageNameStar.js @@ -0,0 +1,18 @@ +const endpoint = require("../../../src/controllers/deletePackagesPackageStar.js"); + +describe("Has features expected", () => { + test("endpoint features", () => { + const expected = { + method: "DELETE", + paths: [ "/api/packages/:packageName/star", "/api/themes/:packageName/star" ], + rateLimit: "auth", + successStatus: 204 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); diff --git a/tests/unit/controllers/deletePackagesPackageNameVersionsVersionName.test.js b/tests/unit/controllers/deletePackagesPackageNameVersionsVersionName.test.js new file mode 100644 index 00000000..cf5c9673 --- /dev/null +++ b/tests/unit/controllers/deletePackagesPackageNameVersionsVersionName.test.js @@ -0,0 +1,18 @@ +const endpoint = require("../../../src/controllers/deletePackagesPackageNameVersionsVersionName.js"); + +describe("Has features expected", () => { + test("endpoint features", () => { + const expected = { + method: "DELETE", + paths: [ "/api/packages/:packageName/versions/:versionName", "/api/themes/:packageName/versions/:versionName" ], + rateLimit: "auth", + successStatus: 204 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); diff --git a/tests/unit/controllers/getRoot.test.js b/tests/unit/controllers/getRoot.test.js new file mode 100644 index 00000000..b81e64e9 --- /dev/null +++ b/tests/unit/controllers/getRoot.test.js @@ -0,0 +1,18 @@ +const endpoint = require("../../../src/controllers/getRoot.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/" ], + rateLimit: "generic", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("Has correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); diff --git a/tests/unit/controllers/getStars.test.js b/tests/unit/controllers/getStars.test.js new file mode 100644 index 00000000..022cfd7e --- /dev/null +++ b/tests/unit/controllers/getStars.test.js @@ -0,0 +1,97 @@ +const endpoint = require("../../../src/controllers/getStars.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/api/stars" ], + rateLimit: "generic", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); +}); + +describe("Returns as expected", () => { + test("When 'auth.verifyAuth' fails", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: false, content: "Test Failure" }; } + }; + + const res = await endpoint.logic({}, localContext); + + expect(res.ok).toBe(false); + expect(res.content).toBeDefined(); + expect(res.content.content).toBe("Test Failure"); + }); + + test("When 'db.getStarredPointersByUserID' fails", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: true, content: { id: 1 } }; } + }; + localContext.database = { + getStarredPointersByUserID: () => { return { ok: false, content: "db Test Failure" }; } + }; + + const res = await endpoint.logic({}, localContext); + + expect(res.ok).toBe(false); + expect(res.content).toBeDefined(); + expect(res.content.content).toBe("db Test Failure"); + }); + + test("When the user has no stars", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: true, content: { id: 1 } }; } + }; + localContext.database = { + getStarredPointersByUserID: () => { return { ok: true, content: [] }; } + }; + + const res = await endpoint.logic({}, localContext); + + expect(res.ok).toBe(true); + expect(res.content).toBeArray(); + expect(res.content.length).toBe(0); + }); + + test("When 'db.getPackageCollectionByID' fails", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: true, content: { id: 1 } }; } + }; + localContext.database = { + getStarredPointersByUserID: () => { return { ok: true, content: [ "an_id" ] }; }, + getPackageCollectionByID: () => { return { ok: false, content: "Another DB Error" }; } + }; + + const res = await endpoint.logic({}, localContext); + + expect(res.ok).toBe(false); + expect(res.content.content).toBe("Another DB Error"); + }); + + test("When request succeeds", async () => { + const localContext = context; + localContext.auth = { + verifyAuth: () => { return { ok: true, content: { id: 1 } }; } + }; + localContext.database = { + getStarredPointersByUserID: () => { return { ok: true, content: [ "an_id" ] }; }, + getPackageCollectionByID: () => { return { ok: true, content: {} }; } + }; + localContext.utils = { + constructPackageObjectShort: () => { return { item: "is_a_package" }; } + }; + + const res = await endpoint.logic({}, localContext); + + expect(res.ok).toBe(true); + expect(res.content.item).toBe("is_a_package"); + }); +}); diff --git a/tests/unit/controllers/getThemes.test.js b/tests/unit/controllers/getThemes.test.js new file mode 100644 index 00000000..ff6e25a6 --- /dev/null +++ b/tests/unit/controllers/getThemes.test.js @@ -0,0 +1,52 @@ +const endpoint = require("../../../src/controllers/getThemes.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/api/themes" ], + rateLimit: "generic", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("Has correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); + +describe("Parameters behave as expected", () => { + test("Returns valid 'page'", () => { + const req = { + query: { + page: "1" + } + }; + + const res = endpoint.params.page(context, req); + expect(res).toBe(1); + }); + test("Returns valid 'sort'", () => { + const req = { + query: { + sort: "downloads" + } + }; + + const res = endpoint.params.sort(context, req); + expect(res).toBe("downloads"); + }); + test("Returns valid 'direction'", () => { + const req = { + query: { + direction: "desc" + } + }; + + const res = endpoint.params.direction(context, req); + expect(res).toBe("desc"); + }); +}); diff --git a/tests/unit/controllers/getThemesSearch.test.js b/tests/unit/controllers/getThemesSearch.test.js new file mode 100644 index 00000000..655d5fb7 --- /dev/null +++ b/tests/unit/controllers/getThemesSearch.test.js @@ -0,0 +1,54 @@ +const endpoint = require("../../../src/controllers/getThemesSearch.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/api/themes/search" ], + rateLimit: "generic", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("Has correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); + +describe("Parameters behave as expected", () => { + test("Returns valid 'sort'", () => { + const req = { + query: { sort: "downloads" } + }; + + const res = endpoint.params.sort(context, req); + expect(res).toBe("downloads"); + }); + test("Returns valid 'page'", () => { + const req = { + query: { page: "1" } + }; + + const res = endpoint.params.page(context, req); + expect(res).toBe(1); + }); + test("Returns valid 'direction'", () => { + const req = { + query: { direction: "desc" } + }; + + const res = endpoint.params.direction(context, req); + expect(res).toBe("desc"); + }); + test("Returns valid 'query'", () => { + const req = { + query: { q: "hello" } + }; + + const res = endpoint.params.query(context, req); + expect(res).toBe("hello"); + }); +}); diff --git a/tests/unit/controllers/getUpdates.test.js b/tests/unit/controllers/getUpdates.test.js new file mode 100644 index 00000000..38797558 --- /dev/null +++ b/tests/unit/controllers/getUpdates.test.js @@ -0,0 +1,31 @@ +const endpoint = require("../../../src/controllers/getUpdates.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + rateLimit: "generic", + successStatus: 200, + paths: [ "/api/updates" ] + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("Has correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + }); +}); + +describe("Functions as expected", () => { + test("Returns correct SSO Object", async () => { + const sso = await endpoint.logic( + {}, + require("../../../src/context.js") + ); + + expect(sso.ok).toBe(false); + expect(sso.short).toBe("not_supported"); + + }); +}); diff --git a/tests/unit/controllers/getUsers.test.js b/tests/unit/controllers/getUsers.test.js new file mode 100644 index 00000000..65ac2dca --- /dev/null +++ b/tests/unit/controllers/getUsers.test.js @@ -0,0 +1,49 @@ +const endpoint = require("../../../src/controllers/getUsers.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/api/users" ], + rateLimit: "auth", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + + test("Has correct functions", () => { + expect(endpoint.logic).toBeTypeof("function"); + expect(endpoint.preLogic).toBeTypeof("function"); + expect(endpoint.postLogic).toBeTypeof("function"); + }); +}); + +describe("Parameters function as expected", () => { + test("Returns params as provided", () => { + const req = { + get: (wants) => { + if (wants === "Authorization") { + return "Auth-Token"; + } else { + return ""; + } + } + }; + + const res = endpoint.params.auth(context, req); + + expect(res).toBe("Auth-Token"); + }); + + test("Returns params when missing", () => { + const req = { + get: () => { return ""; } + }; + + const res = endpoint.params.auth(context, req); + + expect(res).toBe(""); + }); +}); diff --git a/tests/unit/controllers/getUsersLogin.test.js b/tests/unit/controllers/getUsersLogin.test.js new file mode 100644 index 00000000..f475b58f --- /dev/null +++ b/tests/unit/controllers/getUsersLogin.test.js @@ -0,0 +1,38 @@ +const endpoint = require("../../../src/controllers/getUsersLogin.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "GET", + paths: [ "/api/users/:login" ], + rateLimit: "generic", + successStatus: 200 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); + +}); + +describe("Parameters function as expected", () => { + test("Returns params as provided", () => { + const req = { + params: { + login: "test-user" + } + }; + + const res = endpoint.params.login(context, req); + + expect(res).toBe("test-user"); + }); + + test("Returns params when missing", () => { + const req = { params: {} }; + + const res = endpoint.params.login(context, req); + + expect(res).toBe(""); + }); +}); diff --git a/tests/unit/controllers/postPackagesPackageNameVersions.test.js b/tests/unit/controllers/postPackagesPackageNameVersions.test.js new file mode 100644 index 00000000..5a1f860a --- /dev/null +++ b/tests/unit/controllers/postPackagesPackageNameVersions.test.js @@ -0,0 +1,13 @@ +const endpoint = require("../../../src/controllers/postPackagesPackageNameVersions.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "POST", + rateLimit: "auth", + successStatus: 201 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); +}); diff --git a/tests/unit/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.test.js b/tests/unit/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.test.js new file mode 100644 index 00000000..9853c087 --- /dev/null +++ b/tests/unit/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.test.js @@ -0,0 +1,24 @@ +const endpoint = require("../../../src/controllers/postPackagesPackageNameVersionsVersionNameEventsUninstall.js"); +const context = require("../../../src/context.js"); + +describe("Has features expected", () => { + test("Has correct endpoint features", () => { + const expected = { + method: "POST", + rateLimit: "auth", + successStatus: 201 + }; + + expect(endpoint.endpoint).toMatchObject(expected); + }); +}); + +describe("Returns as expected", () => { + test("Returns simple OK object", async () => { + const res = await endpoint.logic({}, context); + + expect(res.ok).toBe(true); + expect(res.content).toBeDefined(); + expect(res.content.ok).toBe(true); + }); +}); diff --git a/tests/unit/endpoints.test.js b/tests/unit/endpoints.test.js new file mode 100644 index 00000000..b4c00381 --- /dev/null +++ b/tests/unit/endpoints.test.js @@ -0,0 +1,45 @@ +const endpoints = require("../../src/controllers/endpoints.js"); + +describe("All endpoints are valid", () => { + test("Have expected objects", () => { + + for (const node of endpoints) { + + for (const item in node) { + + const validItems = [ + "docs", + "endpoint", + "params", + "logic", + "preLogic", + "postLogic", + "postReturnHTTP" + ]; + + expect(validItems.includes(item)); + } + } + + }); + + test("Have a valid 'endpoint' object", () => { + for (const node of endpoints) { + const endpoint = node.endpoint; + + expect(endpoint.method).toBeTypeof("string"); + expect(endpoint.method).toBeIncludedBy([ "GET", "POST", "DELETE" ]); + expect(endpoint.paths).toBeArray(); + expect(endpoint.rateLimit).toBeTypeof("string"); + expect(endpoint.rateLimit).toBeIncludedBy([ "generic", "auth" ]); + expect(endpoint.successStatus).toBeTypeof("number"); + expect(endpoint.options).toBeDefined(); + + if (endpoint.endpointKind) { + expect(endpoint.endpointKind).toBeTypeof("string"); + expect(endpoint.endpointKind).toBeIncludedBy([ "raw", "default" ]); + } + } + + }); +}); diff --git a/test/logger.unit.test.js b/tests/unit/logger.test.js similarity index 98% rename from test/logger.unit.test.js rename to tests/unit/logger.test.js index 365bfb66..a445de87 100644 --- a/test/logger.unit.test.js +++ b/tests/unit/logger.test.js @@ -1,4 +1,4 @@ -const logger = require("../src/logger.js"); +const logger = require("../../src/logger.js"); global.console.log = jest.fn(); diff --git a/test/query.unit.test.js b/tests/unit/query.test.js similarity index 99% rename from test/query.unit.test.js rename to tests/unit/query.test.js index aae88196..d4d8c111 100644 --- a/test/query.unit.test.js +++ b/tests/unit/query.test.js @@ -1,4 +1,4 @@ -const query = require("../src/query.js"); +const query = require("../../src/query.js"); // Page Testing diff --git a/test/storage.unit.test.js b/tests/unit/storage.test.js similarity index 64% rename from test/storage.unit.test.js rename to tests/unit/storage.test.js index 8fc17a1f..7264467f 100644 --- a/test/storage.unit.test.js +++ b/tests/unit/storage.test.js @@ -1,18 +1,18 @@ -const storage = require("../src/storage.js"); +const storage = require("../../src/storage.js"); describe("Functions Return Proper Values", () => { test("getBanList Returns Array", async () => { let value = await storage.getBanList(); - expect(Array.isArray(value.content)).toBeTruthy(); + expect(value.content).toBeArray(); }); test("getFeaturedPackages Returns Array", async () => { let value = await storage.getFeaturedPackages(); - expect(Array.isArray(value.content)).toBeTruthy(); + expect(value.content).toBeArray(); }); test("getFeaturedThemes Returns Array", async () => { let value = await storage.getFeaturedThemes(); - expect(Array.isArray(value.content)).toBeTruthy(); + expect(value.content).toBeArray(); }); }); diff --git a/test/utils.unit.test.js b/tests/unit/utils.test.js similarity index 98% rename from test/utils.unit.test.js rename to tests/unit/utils.test.js index 7d6db16e..62779759 100644 --- a/test/utils.unit.test.js +++ b/tests/unit/utils.test.js @@ -1,7 +1,7 @@ -jest.mock("../src/storage.js"); -const getBanList = require("../src/storage.js").getBanList; +jest.mock("../../src/storage.js"); +const getBanList = require("../../src/storage.js").getBanList; -const utils = require("../src/utils.js"); +const utils = require("../../src/utils.js"); describe("isPackageNameBanned Tests", () => { test("Returns true correctly for banned item", async () => { diff --git a/test/webhook.unit.test.js b/tests/unit/webhook.test.js similarity index 94% rename from test/webhook.unit.test.js rename to tests/unit/webhook.test.js index f2727883..7e338e3a 100644 --- a/test/webhook.unit.test.js +++ b/tests/unit/webhook.test.js @@ -1,8 +1,8 @@ -const webhook = require("../src/webhook.js"); +const webhook = require("../../src/webhook.js"); const superagent = require("superagent"); -const logger = require("../src/logger.js"); +const logger = require("../../src/logger.js"); -jest.mock("../src/logger.js", () => { +jest.mock("../../src/logger.js", () => { return { generic: jest.fn(), }; diff --git a/test/github.vcs.test.js b/tests/vcs/github.vcs.test.js similarity index 96% rename from test/github.vcs.test.js rename to tests/vcs/github.vcs.test.js index 7916e96f..fc4d3c8f 100644 --- a/test/github.vcs.test.js +++ b/tests/vcs/github.vcs.test.js @@ -1,5 +1,5 @@ -const GitHub = require("../src/vcs_providers/github.js"); -const httpMock = require("./httpMock.helper.jest.js"); +const GitHub = require("../../src/vcs_providers/github.js"); +const httpMock = require("../helpers/httpMock.helper.jest.js"); const webRequestMockHelper = (data) => { const tmpMock = jest diff --git a/test/vcs.unit.test.js b/tests/vcs/vcs.unit.test.js similarity index 98% rename from test/vcs.unit.test.js rename to tests/vcs/vcs.unit.test.js index 5c25006d..2a8a13c0 100644 --- a/test/vcs.unit.test.js +++ b/tests/vcs/vcs.unit.test.js @@ -1,4 +1,4 @@ -const vcs = require("../src/vcs.js"); +const vcs = require("../../src/vcs.js"); describe("determineProvider Returns as expected", () => { test("Returns null when no input is passed", () => { diff --git a/test/vcs.vcs.test.js b/tests/vcs/vcs.vcs.test.js similarity index 99% rename from test/vcs.vcs.test.js rename to tests/vcs/vcs.vcs.test.js index 54e6cc61..d0bbb473 100644 --- a/test/vcs.vcs.test.js +++ b/tests/vcs/vcs.vcs.test.js @@ -1,6 +1,6 @@ -const httpMock = require("./httpMock.helper.jest.js"); +const httpMock = require("../helpers/httpMock.helper.jest.js"); -const vcs = require("../src/vcs.js"); +const vcs = require("../../src/vcs.js"); let http_cache = { pack1: {}, // pack1 will be used for newPackageData tests