├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── ISSUE_TEMPLATE │ ├── Bug_report.md │ ├── Feature_request.md │ └── share_playground.png ├── .gitignore ├── .prettierrc ├── .snyk ├── .travis.yml ├── LICENSE ├── README.md ├── auth0-rules ├── README.md └── add-accountLinkId-to-token.js ├── commitizen.config.js ├── commitlint.config.js ├── jest.config.js ├── package.json ├── sample.env ├── scripts ├── base64encode.js ├── deploy.sh └── generateHeader.js ├── serverless.yml ├── src ├── _webpack │ └── include.js ├── auth │ ├── auth0.js │ └── index.js ├── config │ └── secrets.js ├── dataLayer │ ├── index.js │ ├── model │ │ ├── communityEvent.js │ │ └── user.js │ └── mongo │ │ ├── __snapshots__ │ │ ├── event-datalayer.test.js.snap │ │ └── user-dataLayer.test.js.snap │ │ ├── communityEvent.js │ │ ├── event-datalayer.test.js │ │ ├── user-dataLayer.test.js │ │ └── user.js ├── graphql │ ├── errors │ │ └── index.js │ ├── resolvers │ │ ├── communityEvent.js │ │ ├── directives.js │ │ ├── directives.test.js │ │ ├── index.js │ │ └── user.js │ └── typeDefs │ │ ├── CommunityEvent │ │ ├── index.js │ │ ├── input.js │ │ ├── mutation.js │ │ ├── query.js │ │ └── type.js │ │ ├── User │ │ ├── index.js │ │ ├── input.js │ │ ├── mutation.js │ │ ├── query.js │ │ └── type.js │ │ ├── directives │ │ └── index.js │ │ └── index.js ├── handler.js └── utils │ ├── asyncErrorHandler.js │ └── index.js ├── test ├── integration │ ├── __snapshots__ │ │ ├── eventFlow.test.js.snap │ │ └── userFlow.test.js.snap │ ├── eventFlow.test.js │ └── userFlow.test.js └── utils │ ├── setup.js │ ├── teardown.js │ └── test-environment.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": ["transform-es2015-modules-commonjs"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .webpack -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "freecodecamp" 3 | } -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at team@freecodecamp.org. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributor's Guide 2 | 3 | We welcome pull requests from freeCodeCamp campers (our students) and seasoned JavaScript developers alike! 4 | 5 | Follow these steps to contribute: 6 | 7 | 1. Find an issue that needs assistance by searching for the [Help Wanted](https://github.com/freeCodeCamp/opena-api/labels/help%20wanted) tag. 8 | 9 | 2. Let us know you are working on it by posting a comment on the issue. 10 | 11 | 3. Follow the [Contribution Guidelines](#contribution-guidelines) to start working on the issue. 12 | 13 | Remember to feel free to ask for help in our [general Contributors](https://gitter.im/FreeCodeCamp/Contributors) or [open-api](https://gitter.im/FreeCodeCamp/open-api) Gitter rooms. 14 | 15 | Working on your first Pull Request? You can learn how from this *free* series [How to Contribute to an Open Source Project on GitHub](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github) 16 | 17 | -------------------------------------------------------------------------------- 18 | ## Quick Reference 19 | 20 | |command|description| 21 | |---|---| 22 | | `yarn test` | run all tests | 23 | | `yarn commit` | interactive tool to help you build a good commit message | 24 | | `yarn start ` | starts your Lambda locally | 25 | | `yarn deploy-dev ` | deploy your Lambda to a development environment. Requires an AWS account. | 26 | | `yarn generate-auth-header` | generate headers for local testing | 27 | | `yarn encode-file-contents` | base64 encode the contents of a file | 28 | 29 | ## Table of Contents 30 | 31 | ### [Setup](#setup) 32 | 33 | - [Forking the Project](#forking-the-project) 34 | - [Create a Branch](#create-a-branch) 35 | - [Set Up](#set-up-open-api) 36 | - [Running](#run-open-api) 37 | 38 | ### [Make Changes](#make-changes) 39 | 40 | - [Run The Test Suite](#run-the-test-suite) 41 | 42 | ### [Submit](#submit) 43 | 44 | - [Creating a Pull Request](#creating-a-pull-request) 45 | - [Common Steps](#common-steps) 46 | - [How We Review and Merge Pull Requests](#how-we-review-and-merge-pull-requests) 47 | - [How We Close Stale Issues](#how-we-close-stale-issues) 48 | - [Next Steps](#next-steps) 49 | - [Other Resources](#other-resources) 50 | 51 | ## Setup 52 | 53 | ### Prerequisites 54 | 55 | | Prerequisite | Version | 56 | | ------------------------------------------- | ------- | 57 | | [Yarn](https://yarnpkg.com/en/docs/install) | | 58 | | [Docker](https://docs.docker.com/install) | | 59 | | [Node.js](http://nodejs.org) | `~ ^8.10` | 60 | 61 | ### Forking the Project 62 | 63 | #### Setting Up Your System 64 | 65 | 1. Install [Git](https://git-scm.com/) or your favorite Git client. 66 | 2. (Optional) [Setup an SSH Key](https://help.github.com/articles/generating-an-ssh-key/) for GitHub. 67 | 3. Install Yarn 68 | 4. Install Docker 69 | 5. Install Node 70 | 6. Pull the Lambda image to simulate the AWS Lambda environment: `docker pull lambci/lambda` 71 | 72 | 73 | #### Forking freeCodeCamp 74 | 75 | 1. Go to the top level open-api repository: 76 | 2. Click the "Fork" Button in the upper right hand corner of the interface ([More Details Here](https://help.github.com/articles/fork-a-repo/)) 77 | 3. After the repository (repo) has been forked, you will be taken to your copy of the freeCodeCamp repo at 78 | 79 | #### Cloning Your Fork 80 | 81 | 1. Open a Terminal / Command Line / Bash Shell in your projects directory (_i.e.: `/yourprojectdirectory/`_) 82 | 2. Clone your fork of open-api 83 | 84 | ```shell 85 | $ git clone https://github.com/yourUsername/open-api.git 86 | ``` 87 | 88 | **(make sure to replace `yourUsername` with your GitHub username)** 89 | 90 | This will download the entire freeCodeCamp repo to your projects directory. 91 | 92 | #### Setup Your Upstream 93 | 94 | 1. Change directory to the new open-api directory (`cd opena-api`) 95 | 2. Add a remote to the official open-api repo: 96 | 97 | ```shell 98 | $ git remote add upstream https://github.com/freeCodeCamp/open-api.git 99 | ``` 100 | 101 | Congratulations, you now have a local copy of the open-api repo! 102 | 103 | #### Maintaining Your Fork 104 | 105 | Now that you have a copy of your fork, there is work you will need to do to keep it current. 106 | 107 | ##### Rebasing from Upstream 108 | 109 | Do this prior to every time you create a branch for a PR: 110 | 111 | 1. Make sure you are on the `staging` branch 112 | 113 | ```shell 114 | $ git status 115 | On branch staging 116 | Your branch is up-to-date with 'origin/staging'. 117 | ``` 118 | If your aren't on `staging`, resolve outstanding files / commits and checkout the `staging` branch 119 | 120 | ```shell 121 | $ git checkout staging 122 | ``` 123 | 124 | 2. Do a pull with rebase against `upstream` 125 | 126 | ```shell 127 | $ git pull --rebase upstream staging 128 | ``` 129 | 130 | This will pull down all of the changes to the official staging branch, without making an additional commit in your local repo. 131 | 132 | 3. (_Optional_) Force push your updated staging branch to your GitHub fork 133 | 134 | ```shell 135 | $ git push origin staging --force 136 | ``` 137 | 138 | This will overwrite the staging branch of your fork. 139 | 140 | ### Create a Branch 141 | 142 | Before you start working, you will need to create a separate branch specific to the issue / feature you're working on. You will push your work to this branch. 143 | 144 | #### Naming Your Branch 145 | 146 | Name the branch something like `fix/xxx` or `feature/xxx` where `xxx` is a short description of the changes or feature you are attempting to add. For example `fix/email-login` would be a branch where you fix something specific to email login. 147 | 148 | #### Adding Your Branch 149 | 150 | To create a branch on your local machine (and switch to this branch): 151 | 152 | ```shell 153 | $ git checkout -b [name_of_your_new_branch] 154 | ``` 155 | 156 | and to push to GitHub: 157 | 158 | ```shell 159 | $ git push origin [name_of_your_new_branch] 160 | ``` 161 | 162 | **If you need more help with branching, take a look at [this](https://github.com/Kunena/Kunena-Forum/wiki/Create-a-new-branch-with-git-and-manage-branches).** 163 | 164 | ### Set Up Linting 165 | 166 | You should have [ESLint running in your editor](http://eslint.org/docs/user-guide/integrations.html), and it will highlight anything doesn't conform to [freeCodeCamp's JavaScript Style Guide](http://forum.freecodecamp.org/t/free-code-camp-javascript-style-guide/19121) (you can find a summary of those rules [here](https://github.com/freeCodeCamp/freeCodeCamp/blob/staging/.eslintrc)). 167 | 168 | > Please do not ignore any linting errors, as they are meant to **help** you and to ensure a clean and simple code base. 169 | 170 | ### Set Up 171 | 172 | Once you have open-api cloned, before you start the application, you first need to install all of the dependencies: 173 | 174 | ```bash 175 | # Install dependencies 176 | yarn 177 | ``` 178 | 179 | Then you need to add the private environment variables (API Keys): 180 | 181 | ```bash 182 | # Create a copy of the "sample.env" and name it as ".env". 183 | # Populate it with the necessary API keys and secrets: 184 | 185 | # macOS / Linux 186 | cp sample.env .env 187 | 188 | # Windows 189 | copy sample.env .env 190 | ``` 191 | Then edit the `.env` file and modify the keys only for parts that you will use. 192 | 193 | Note: Not all keys are required, to run the app locally, however `MONGODB_URL` is the most important one. Unless you have MongoDB running in a setup different than the defaults, the URL in the sample.env should work fine. 194 | 195 | You can leave the other keys as they are. 196 | 197 | Set up freeCodeCamp which will provide you with a pre-seeded Mongo container. Find instructions at https://github.com/freeCodeCamp/freeCodeCamp/blob/staging/CONTRIBUTING.md#setup-freecodecamp-using-docker 198 | 199 | ### Run 200 | 201 | Start a local container emulating the AWS Lambda environment 202 | ```bash 203 | ▶ yarn start 204 | yarn run v1.6.0 205 | $ cross-env DEBUG=fcc:* nodemon node_modules/serverless/bin/serverless offline start --skipCacheInvalidation 206 | [nodemon] 1.17.3 207 | [nodemon] to restart at any time, enter `rs` 208 | [nodemon] watching: *.* 209 | [nodemon] starting `node node_modules/serverless/bin/serverless offline start --skipCacheInvalidation` 210 | 211 | Serverless: Bundling with Webpack... 212 | Time: 1002ms 213 | Built at: 2018-05-08 11:47:35 214 | Asset Size Chunks Chunk Names 215 | src/handler.js 41.4 KiB src/handler [emitted] src/handler 216 | src/handler.js.map 30.5 KiB src/handler [emitted] src/handler 217 | Entrypoint src/handler = src/handler.js src/handler.js.map 218 | [./src/_webpack/include.js] 406 bytes {src/handler} [built] 219 | [./src/dataLayer/index.js] 491 bytes {src/handler} [built] 220 | [./src/graphql/resolvers/directives.js] 933 bytes {src/handler} [built] 221 | [./src/graphql/resolvers/index.js] 193 bytes {src/handler} [built] 222 | [./src/graphql/typeDefs/HTTPStatus/index.js] 77 bytes {src/handler} [built] 223 | [./src/graphql/typeDefs/index.js] 262 bytes {src/handler} [built] 224 | [./src/handler.js] 2.14 KiB {src/handler} [built] 225 | [apollo-server-lambda] external "apollo-server-lambda" 42 bytes {src/handler} [built] 226 | [debug] external "debug" 42 bytes {src/handler} [built] 227 | [graphql-playground-middleware-lambda] external "graphql-playground-middleware-lambda" 42 bytes {src/handler} [built] 228 | [0] multi ./src/_webpack/include.js ./src/handler.js 40 bytes {src/handler} [built] 229 | [graphql-tools] external "graphql-tools" 42 bytes {src/handler} [built] 230 | [merge-graphql-schemas] external "merge-graphql-schemas" 42 bytes {src/handler} [built] 231 | [mongoose] external "mongoose" 42 bytes {src/handler} [built] 232 | [source-map-support/register] external "source-map-support/register" 42 bytes {src/handler} [built] 233 | + 25 hidden modules 234 | Serverless: Watching for changes... 235 | Serverless: Starting Offline: dev/us-east-1. 236 | 237 | Serverless: Routes for graphql: 238 | Serverless: POST /graphql 239 | 240 | Serverless: Routes for api: 241 | Serverless: GET /api 242 | 243 | Serverless: Offline listening on http://localhost:4000 244 | ``` 245 | 246 | You should now be able to interact the GraphQL server directly or by using the GraphQL IDE at http://localhost:4000/api . 247 | 248 | For authenticated endpoints you'll need to pass a valid token in the authorization headers. For a local instance you can generate one by running: 249 | 250 | ``` 251 | yarn generate-auth-header 252 | ``` 253 | 254 | And then add those to "http headers" in http://localhost:4000/api. 255 | 256 | ## Make Changes 257 | 258 | This bit is up to you! 259 | 260 | ### Add tests 261 | 262 | If you have added functionality, please add unit, integration tests or both. 263 | 264 | Place unit tests close to the code they test, in the same directory and file name, ie: `foo.test.js` tests `foo.js`. 265 | 266 | Place integration tests in test/integration. We just Jest for integration tests. 267 | 268 | ### Run The Test Suite 269 | 270 | When you're ready to share your code, run the test suite: 271 | 272 | ```shell 273 | yarn test 274 | ``` 275 | 276 | and ensure all tests pass. 277 | 278 | ### Creating a Pull Request 279 | 280 | #### What is a Pull Request? 281 | 282 | A pull request (PR) is a method of submitting proposed changes to the open-api 283 | repo (or any repo, for that matter). You will make changes to copies of the 284 | files which make up open-api in a personal fork, then apply to have them 285 | accepted by open-api proper. 286 | 287 | #### Need Help? 288 | 289 | freeCodeCamp Issue Mods and staff are on hand to assist with Pull Request 290 | related issues in our [general Contributors](https://gitter.im/FreeCodeCamp/Contributors) or [open-api](https://gitter.im/FreeCodeCamp/open-api) Gitter rooms. 291 | 292 | #### Important: ALWAYS EDIT ON A BRANCH 293 | 294 | Take away only one thing from this document: Never, **EVER** 295 | make edits to the `staging` branch. ALWAYS make a new branch BEFORE you edit 296 | files. This is critical, because if your PR is not accepted, your copy of 297 | staging will be forever sullied and the only way to fix it is to delete your 298 | fork and re-fork. 299 | 300 | #### Methods 301 | 302 | There are two methods of creating a pull request for freeCodeCamp: 303 | 304 | - Editing files on a local clone (recommended) 305 | - Editing files via the GitHub Interface 306 | 307 | ##### Method 1: Editing via your Local Fork _(Recommended)_ 308 | 309 | This is the recommended method. Read about [How to Setup and Maintain a Local 310 | Instance of open-api](#maintaining-your-fork). 311 | 312 | 1. Perform the maintenance step of rebasing `staging`. 313 | 2. Ensure you are on the `staging` branch using `git status`: 314 | 315 | $ git status 316 | On branch staging 317 | Your branch is up-to-date with 'origin/staging'. 318 | 319 | nothing to commit, working directory clean 320 | 321 | 3. If you are not on staging or your working directory is not clean, resolve 322 | any outstanding files/commits and checkout staging `git checkout staging` 323 | 324 | 4. Create a branch off of `staging` with git: `git checkout -B 325 | branch/name-here` **Note:** Branch naming is important. Use a name like 326 | `fix/short-fix-description` or `feature/short-feature-description`. Review 327 | the [Contribution Guidelines](#contribution-guidelines) for more detail. 328 | 329 | 5. Edit your file(s) locally with the editor of your choice. To edit challenges, you may want to use `unpack` and `repack` -- see [Unpack and Repack](#unpack-and-repack) for instructions. 330 | 331 | 4. Check your `git status` to see unstaged files. 332 | 333 | 5. Add your edited files: `git add path/to/filename.ext` You can also do: `git 334 | add .` to add all unstaged files. Take care, though, because you can 335 | accidentally add files you don't want added. Review your `git status` first. 336 | 337 | 6. Commit your edits: We have a [tool](https://commitizen.github.io/cz-cli/) 338 | that helps you to make standard commit messages. Execute `npm run commit` 339 | and follow the steps. 340 | 341 | 7. [Squash your commits](http://forum.freecodecamp.org/t/how-to-squash-multiple-commits-into-one-with-git/13231) if there are more than one. 342 | 343 | 8. If you would want to add/remove changes to previous commit, add the files as in Step 5 earlier, 344 | and use `git commit --amend` or `git commit --amend --no-edit` (for keeping the same commit message). 345 | 346 | 9. Push your commits to your GitHub Fork: `git push origin branch/name-here` 347 | 348 | 10. Go to [Common Steps](#common-steps) 349 | 350 | ##### Method 2: Editing via the GitHub Interface 351 | 352 | Note: Editing via the GitHub Interface is not recommended, since it is not 353 | possible to update your fork via GitHub's interface without deleting and 354 | recreating your fork. 355 | 356 | Read the [Wiki 357 | article](http://forum.freecodecamp.org/t/how-to-make-a-pull-request-on-free-code-camp/19114) 358 | for further information 359 | 360 | ### Common Steps 361 | 362 | 1. Once the edits have been committed, you will be prompted to create a pull 363 | request on your fork's GitHub Page. 364 | 365 | 2. By default, all pull requests should be against the open-api main repo, `staging` 366 | branch. 367 | **Make sure that your Base Fork is set to freeCodeCamp/open-api when raising a Pull Request.** 368 | 369 | ![fork-instructions](./docs/images/fork-instructions.png) 370 | 371 | 3. Submit a [pull 372 | request](http://forum.freecodecamp.org/t/how-to-contribute-via-a-pull-request/19368) 373 | from your branch to freeCodeCamp's `staging` branch. 374 | 375 | 4. The title (also called the subject) of your PR should be descriptive of your 376 | changes and succinctly indicates what is being fixed. 377 | 378 | - **Do not add the issue number in the PR title or commit message.** 379 | 380 | - Examples: `Add Test Cases to Bonfire Drop It` `Correct typo in Waypoint 381 | Size Your Images` 382 | 383 | 5. In the body of your PR include a more detailed summary of the changes you 384 | made and why. 385 | 386 | - If the PR is meant to fix an existing bug/issue then, at the end of 387 | your PR's description, append the keyword `closes` and #xxxx (where xxxx 388 | is the issue number). Example: `closes #1337`. This tells GitHub to 389 | close the existing issue, if the PR is merged. 390 | 391 | 6. Indicate if you have tested on a local copy of the site or not. 392 | 393 | 394 | ### How We Review and Merge Pull Requests 395 | 396 | freeCodeCamp has a team of volunteer Issue Moderators. These Issue Moderators routinely go through open pull requests in a process called [Quality Assurance](https://en.wikipedia.org/wiki/Quality_assurance) (QA). 397 | 398 | 1. If an Issue Moderator QA's a pull request and confirms that the new code does what it is supposed without seeming to introduce any new bugs, they will comment "LGTM" which means "Looks good to me." 399 | 400 | 2. Another Issue Moderator will QA the same pull request. Once they have also confirmed that the new code does what it is supposed to without seeming to introduce any new bugs, they will merge the pull request. 401 | 402 | If you would like to apply to join our Issue Moderator team, message [@quincylarson](https://gitter.im/quincylarson) with links to 5 of your pull requests that have been accepted and 5 issues where you have helped someone else through commenting or QA'ing. 403 | 404 | ### How We Close Stale Issues 405 | 406 | We will close any issues or pull requests that have been inactive for more than 15 days, except those that match the following criteria: 407 | 408 | - bugs that are confirmed 409 | - pull requests that are waiting on other pull requests to be merged 410 | - features that are a part of a GitHub project 411 | 412 | ### Next Steps 413 | 414 | #### If your PR is accepted 415 | 416 | Once your PR is accepted, you may delete the branch you created to submit it. 417 | This keeps your working fork clean. 418 | 419 | You can do this with a press of a button on the GitHub PR interface. You can 420 | delete the local copy of the branch with: `git branch -D branch/to-delete-name` 421 | 422 | #### If your PR is rejected 423 | 424 | Don't despair! You should receive solid feedback from the Issue Moderators as to 425 | why it was rejected and what changes are needed. 426 | 427 | Many Pull Requests, especially first Pull Requests, require correction or 428 | updating. If you have used the GitHub interface to create your PR, you will need 429 | to close your PR, create a new branch, and re-submit. 430 | 431 | If you have a local copy of the repo, you can make the requested changes and 432 | amend your commit with: `git commit --amend` This will update your existing 433 | commit. When you push it to your fork you will need to do a force push to 434 | overwrite your old commit: `git push --force` 435 | 436 | Be sure to post in the PR conversation that you have made the requested changes. 437 | 438 | ### Other Resources 439 | 440 | * Bugs and Issues: 441 | 442 | - [Searching for Your Issue on GitHub](http://forum.freecodecamp.org/t/searching-for-existing-issues/19139) 443 | 444 | - [Creating a New GitHub Issue](http://forum.freecodecamp.org/t/creating-a-new-github-issue/18392) 445 | 446 | - [Select Issues for Contributing Using Labels](http://forum.freecodecamp.org/t/free-code-camp-issue-labels/19556) 447 | 448 | * Miscellaneous: 449 | 450 | - [How to clone the freeCodeCamp website on a Windows PC](http://forum.freecodecamp.org/t/how-to-clone-and-setup-the-free-code-camp-website-on-a-windows-pc/19366) 451 | 452 | - [How to log in to your local freeCodeCamp site using GitHub](http://forum.freecodecamp.org/t/how-to-log-in-to-your-local-instance-of-free-code-camp/19552) 453 | 454 | - [Writing great git commit messages](http://forum.freecodecamp.org/t/writing-good-git-commit-messages/13210) 455 | 456 | - [General Contributor Chat Support](https://gitter.im/FreeCodeCamp/Contributors) - for the freeCodeCamp repositories, and running a local instance 457 | 458 | - [open-api Chat Support](https://gitter.im/FreeCodeCamp/open-api) - for the freeCodeCamp repositories, and running a local instance 459 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | #### Trying to report a security issue? #### 8 | 👌 please report security issues to security@freecodecamp.org instead of raising a Github issue. 9 | We look forward to working with you. If the issue is significant we'll work on resolving it as quickly as we can. We'll be happy to mention you in a published list of security researchers that found issues in our projects if you so desire. 10 | 11 | #### Describe the bug #### 12 | 13 | A clear and concise description of what the bug is. 14 | 15 | #### To Reproduce #### 16 | 17 | Steps to reproduce the behavior. We prefer a GraphQL query or link to a graphqlbin to reproduce. 18 | 19 | *be mindful that graphbin will include HTTP headers, take care when 20 | setting authentication tokens.* 21 | 22 | playground 23 | 24 | 25 | #### Expected behavior #### 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | #### Screenshots #### 30 | 31 | If applicable, add screenshots to help explain your problem. 32 | 33 | #### Additional context #### 34 | 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | #### Is your feature request related to a problem? Please describe. #### 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | #### Describe the solution you'd like #### 11 | A clear and concise description of what you want to happen. 12 | 13 | #### Describe alternatives you've considered #### 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | #### Additional context #### 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/share_playground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/freeCodeCamp/open-api/68b2ae532c8959df01ff8dd2e203f73c3e2276e8/.github/ISSUE_TEMPLATE/share_playground.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # Webpack 64 | .webpack 65 | 66 | # Serverless 67 | .serverless 68 | .webpack 69 | 70 | # Lockfile 71 | package-lock.json 72 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.11.0 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | 'npm:hoek:20180212': 6 | - '*': 7 | reason: 'Low sev, no fix available' 8 | expires: 2018-05-20T02:45:20.389Z 9 | 'npm:https-proxy-agent:20180402': 10 | - '*': 11 | reason: no-patch 12 | expires: 2018-05-20T00:00:00.000Z 13 | # patches apply the minimum changes required to fix a vulnerability 14 | patch: 15 | 'npm:minimatch:20160620': 16 | - gulp > vinyl-fs > glob-stream > minimatch: 17 | patched: '2018-04-26T06:51:19.797Z' 18 | - gulp-nodemon > gulp > vinyl-fs > glob-stream > minimatch: 19 | patched: '2018-04-26T06:51:19.797Z' 20 | - gulp-nodemon > gulp > vinyl-fs > glob-stream > glob > minimatch: 21 | patched: '2018-04-26T06:51:19.797Z' 22 | - gulp > vinyl-fs > glob-watcher > gaze > globule > minimatch: 23 | patched: '2018-04-26T06:51:19.797Z' 24 | - gulp > vinyl-fs > glob-watcher > gaze > globule > glob > minimatch: 25 | patched: '2018-04-26T06:51:19.797Z' 26 | - gulp-nodemon > gulp > vinyl-fs > glob-watcher > gaze > globule > minimatch: 27 | patched: '2018-04-26T06:51:19.797Z' 28 | - gulp-nodemon > gulp > vinyl-fs > glob-watcher > gaze > globule > glob > minimatch: 29 | patched: '2018-04-26T06:51:19.797Z' 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - 8 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | - "$HOME/.mongodb-binaries" 10 | env: 11 | global: 12 | - AWS_REGION=us-east-1 13 | - SLS_DEBUG=true 14 | before_install: 15 | - yarn global add serverless 16 | - export PATH=$(yarn global dir)/node_modules/.bin:$PATH 17 | script: 18 | - yarn lint 19 | - yarn test-ci 20 | - if [ "${TRAVIS_PULL_REQUEST}" = "true" ] ; then commitlint-travis ; fi 21 | - if [ "${TRAVIS_PULL_REQUEST}" = "false" ] ; then ./scripts/deploy.sh ; fi 22 | notifications: 23 | webhooks: 24 | urls: 25 | secure: zLYVljryRBr2aVzx0lt0T3MhiRvc3AHyIVRzpds1IrLHIVias99/PJ0TG51XlDZloNA2eLlj/9BvP9AIH6ycLSSP9JrNstV4f6MDJg4h/ASRDbCLQOuRzDHs35Q/+eqFqw26zfmE6/wJSukykd/9eZcAVDItj3oLyOYjyz5SSeq8vgozKdIHbG/17lKOEVESGvcbo2uxYOYTjAyYRiKNlIt8owB85nylS79isvZ2UwYpkv5Ikcvj/TtQXlwoe8KAUxLUP6pSa+pinL5cLzwvfwcErd9DPpc5/kd/yvU0kV5BvbJUW8MSufaA0JYqEQbyeqIWKyPtfzJU8SqVBMLpnQiNN6tTIb/FVO7cKkyMQVWMW1QvTtvA/l9sWceA3hQ9pqjOOqUKAQ5JvPLHzc+MspGkl1DU3RVk4A462C8JeBGEhJggDh/vDhcN0vUVfO4OMWEqu/vgpTpggeBOev3hZHwNHEOSWH4Bq00OMbKL32FkabPWiRRbPB5DQEeA8NpYhNk4mKpJRye+9LoogmK6wrYrh1ngTwMT10/k/61PyQezEhSTRktyvy/cx3OsckCZmg1ezYsrv/QY85fe0eLZkloVNkzM9QDSFi8+t696EvElFjQQ5PTVs8gzNI9s9VN3bkmmWLWXt91oyU9hcDf7tSjqSyYsSYhF+HxCqhEVUAU= 26 | on_success: change 27 | on_failure: always 28 | on_start: never 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2018, freeCodeCamp 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![freeCodeCamp](https://camo.githubusercontent.com/60c67cf9ac2db30d478d21755289c423e1f985c6/68747470733a2f2f73332e616d617a6f6e6177732e636f6d2f66726565636f646563616d702f776964652d736f6369616c2d62616e6e65722e706e67) 2 | 3 | [![Gitter](https://badges.gitter.im/FreeCodeCamp/open-api.svg)](https://gitter.im/FreeCodeCamp/open-api?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 4 | [![Build Status](https://travis-ci.org/freeCodeCamp/open-api.svg?branch=staging)](https://travis-ci.org/freeCodeCamp/open-api) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/freecodecamp/open-api/badge.svg?targetFile=package.json)](https://snyk.io/test/github/freecodecamp/open-api?targetFile=package.json) 6 | 7 | # open-api 8 | 9 | **This project is currently being refactored, performance and API will be unstable.** 10 | 11 | ## About 12 | 13 | open-api is a graphQL API that will serve multiple purposes: 14 | 15 | * serve the frontend of freeCodeCamp 16 | * an implementation of the freeCodeCamp's open-data policy 17 | * allow developers to build applications around the freeCodeCamp's eco-system and its open data sets 18 | 19 | ## Urls 20 | 21 | | environment | url | method | 22 | ------- | --- | ---| 23 | | staging | https://hxtsoafqna.execute-api.us-east-1.amazonaws.com/stage/api | GET | 24 | | staging | https://hxtsoafqna.execute-api.us-east-1.amazonaws.com/stage/graphql | POST | 25 | | production | 26 | | production | 27 | 28 | ## Contributing 29 | 30 | We welcome pull requests 🎉! Please follow [these steps](.github/CONTRIBUTING.md) to contribute. 31 | 32 | ## Updating certificates 33 | 34 | Tokens are verified using public keys, each tenant will have their own certificate containing the public key. 35 | 36 | Certificates are stored either on developer laptops in .env files, or in an environment variable 37 | JWT_CERT for deployments. We use Travis for deployments, and `scripts/deploy.sh` 38 | will pick either JWT_CERT_STAGE or JWT_CERT_PROD and export it as JWT_CERT. This 39 | will be picked up and deployed by Serverless. 40 | 41 | Certificates are base64 encoded to prevent encoding issues. This works around the 42 | fact that Travis uses Bash to export environment variables, which causes issues 43 | with newlines and other characters have a special meaning in shells. 44 | 45 | To add a new certificate, download it as a .pem file, and base64 encode it. Use `yarn encode-file` if you want a 46 | convenient script: 47 | 48 | ```bash 49 | ▶ yarn encode-file ~/Downloads/freecodecamp-dev.pem 50 | yarn run v1.6.0 51 | $ node scripts/base64encode.js /Users/ojongerius/Downloads/freecodecamp-dev.pem 52 | Original contents: 53 | 54 | -----BEGIN CERTIFICATE----- 55 | MIIDDzCCAfegAwIBAgIJGHAmUeq9oGcAMA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV 56 | 57 | zIPPbMj9c6D7tETg2ZeHEthScPsgoPSHXxYu5N9ImoY/KLjDD5Nk364e0M+ZT8rF 58 | rbgxgxHNJH92enBwsqrq7CWi2Q== 59 | -----END CERTIFICATE----- 60 | 61 | Base64 encoded (copy this): 62 | 63 | LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tDQpNSUlERHpDQ0FmZWdBd0lCQWdJSkdIQW1VZXE5b0djQU1B 64 | 65 | MzY0ZTBNK1pUOHJGDQpyYmd4Z3hITkpIOTJlbkJ3c3FycTdDV2kyUT09DQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tDQo= 66 | ✨ Done in 0.23s. 67 | ``` 68 | 69 | And copy the base64 encoded string to your destination. 70 | 71 | ## Deployment 72 | 73 | Deployment is normally done by CI. 74 | 75 | ### Manual Deployment 76 | 77 | If you want to do a manual deployment: 78 | 79 | Configure your AWS credentials, see 80 | 81 | Ensure that you have the `serverless` package install globally 82 | 83 | ```sh 84 | yarn global add serverless 85 | ``` 86 | 87 | Assert that the stages configured in `serverless.yml` in line with what you'd like to deploy to, and run: 88 | 89 | ```sh 90 | serverless --stage=YOUR_STAGE_HERE deploy 91 | ``` 92 | 93 | ## Getting an API key 94 | 95 | TBD 96 | 97 | ## License 98 | 99 | Copyright (c) 2018 freeCodeCamp. 100 | 101 | The computer software in this repository is licensed under the [BSD-3-Clause](./LICENSE). 102 | -------------------------------------------------------------------------------- /auth0-rules/README.md: -------------------------------------------------------------------------------- 1 | ## Auth0 Rules 2 | 3 | The functions held in this directory have no direct effect inside the open-api, but are held here for visability and code review. 4 | 5 | They are subject to a manual deploy process, that is to say, we copy/paste them inside the Auth0 dashboard. 6 | -------------------------------------------------------------------------------- /auth0-rules/add-accountLinkId-to-token.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /* 4 | 5 | Function name should be removed for deployment 6 | 7 | uuid is global inside Auth0 Rules 8 | 9 | https://auth0.com/docs/appliance/modules 10 | 11 | */ 12 | 13 | function addAccountLinkId(user, context, callback) { 14 | const namespace = 'https://auth-ns.freecodecamp.org/'; 15 | user.app_metadata = user.app_metadata || {}; 16 | user.app_metadata.accountLinkId = user.app_metadata.accountLinkId || uuid(); 17 | context.idToken[namespace + 'accountLinkId'] = 18 | user.app_metadata.accountLinkId; 19 | 20 | callback(null, user, context); 21 | } 22 | -------------------------------------------------------------------------------- /commitizen.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const types = [ 4 | { 5 | value: 'feat', 6 | name: 'feat: A new feature' 7 | }, 8 | { 9 | value: 'fix', 10 | name: 'fix: A bug fix' 11 | }, 12 | { 13 | value: 'docs', 14 | name: 'docs: Documentation only changes' 15 | }, 16 | { 17 | value: 'style', 18 | name: `style: Changes that do not affect the meaning of the code 19 | (white-space, formatting, missing semi-colons, etc)` 20 | }, 21 | { 22 | value: 'refactor', 23 | name: 'refactor: A code change that neither fixes a bug nor adds a feature' 24 | }, 25 | { 26 | value: 'perf', 27 | name: 'perf: A code change that improves performance' 28 | }, 29 | { 30 | value: 'test', 31 | name: 'test: Adding missing tests' 32 | }, 33 | { 34 | value: 'chore', 35 | name: `chore: Changes to the build process or auxiliary tools 36 | and libraries such as documentation generation` 37 | }, 38 | { 39 | value: 'revert', 40 | name: 'revert: Revert a commit' 41 | } 42 | ]; 43 | 44 | const scopes = ['tools', 'dependencies'].map(name => ({ 45 | name 46 | })); 47 | 48 | module.exports = { 49 | types, 50 | scopes, 51 | allowCustomScopes: true, 52 | allowBreakingChanges: ['feat', 'fix', 'perf', 'refactor'] 53 | }; 54 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const { types, scopes, allowCustomScopes } = require('./commitizen.config'); 4 | 5 | const validTypes = types.map(type => type.value); 6 | const validScopes = scopes.map(scope => scope.name); 7 | const scopeValidationLevel = allowCustomScopes ? 0 : 2; 8 | 9 | module.exports = { 10 | extends: ['@commitlint/config-conventional'], 11 | 12 | // Add your own rules. See http://marionebl.github.io/commitlint 13 | rules: { 14 | // Apply valid scopes and types 15 | 'scope-enum': [scopeValidationLevel, 'always', validScopes], 16 | 'type-enum': [2, 'always', validTypes], 17 | 18 | // Disable subject-case rule 19 | 'subject-case': [0, 'always'], 20 | 21 | // Disable language rule 22 | lang: [0, 'always', 'eng'] 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: './test/utils/setup.js', 3 | globalTeardown: './test/utils/teardown.js', 4 | testEnvironment: './test/utils/test-environment.js' 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "open-api", 3 | "description": "freeCodeCamp's open-api Intiative", 4 | "license": "BSD-3-Clause", 5 | "author": "freeCodeCamp (https://freecodecamp.org)", 6 | "homepage": "https://github.com/freeCodeCamp/open-api#readme", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/freeCodeCamp/open-api.git" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/freeCodeCamp/open-api/issues" 13 | }, 14 | "version": "0.0.1", 15 | "main": "handler.js", 16 | "scripts": { 17 | "precommit": "lint-staged", 18 | "commit": "git-cz", 19 | "commitmsg": "commitlint -e", 20 | "deploy-dev": "serverless --stage=dev deploy", 21 | "deploy-prod": "serverless --stage=prod deploy", 22 | "encode-file-contents": "node scripts/base64encode.js", 23 | "format": "prettier --write es5 './**/*.{js,json}' && yarn lint", 24 | "generate-auth-header": "node scripts/generateHeader", 25 | "lint": "eslint ./**/*.js --fix", 26 | "prepare-production": "snyk protect", 27 | "prepublishOnly": "yarn snyk-protect", 28 | "snyk-protect": "snyk protect", 29 | "snyk-test": "snyk test", 30 | "start": 31 | "cross-env DEBUG=fcc:* nodemon node_modules/serverless/bin/serverless offline start --skipCacheInvalidation --stage dev", 32 | "test": "cross-env JWT_CERT=test jest --runInBand --verbose --silent", 33 | "test-ci": "cross-env JWT_CERT=test jest --runInBand --verbose --silent", 34 | "test-update-snapshot": 35 | "cross-env JWT_CERT=test jest --runInBand --verbose --updateSnapshot" 36 | }, 37 | "config": { 38 | "commitizen": { 39 | "path": "node_modules/cz-customizable" 40 | }, 41 | "cz-customizable": { 42 | "config": "commitizen.config.js" 43 | } 44 | }, 45 | "dependencies": { 46 | "ajv": "^6.4.0", 47 | "apollo-errors": "^1.9.0", 48 | "apollo-server-lambda": "^1.3.4", 49 | "auth0-js": "^9.5.0", 50 | "aws-lambda": "^0.1.2", 51 | "axios": "^0.18.0", 52 | "babel-core": "^6.26.0", 53 | "babel-loader": "^7.1.4", 54 | "dotenv": "^5.0.1", 55 | "graphql": "0.13.2", 56 | "graphql-playground-middleware-lambda": "1.4.4", 57 | "graphql-tools": "^2.24.0", 58 | "jsonwebtoken": "^8.2.1", 59 | "lodash": "^4.17.4", 60 | "merge-graphql-schemas": "1.5.1", 61 | "moment": "^2.20.1", 62 | "mongoose": "^5.0.12", 63 | "serverless": "^1.26.1", 64 | "serverless-offline": "^3.20.0", 65 | "serverless-offline-scheduler": "^0.3.3", 66 | "serverless-webpack": "^5.1.1", 67 | "snyk": "^1.73.0", 68 | "uuid": "^3.2.1", 69 | "validator": "^9.4.1", 70 | "webpack": "^4.5.0", 71 | "webpack-node-externals": "^1.7.2" 72 | }, 73 | "devDependencies": { 74 | "@commitlint/cli": "^6.1.3", 75 | "@commitlint/config-conventional": "^6.1.3", 76 | "@commitlint/travis-cli": "^6.1.3", 77 | "babel-jest": "^22.4.3", 78 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 79 | "commitizen": "^2.9.6", 80 | "cross-env": "^5.1.4", 81 | "cz-customizable": "^5.2.0", 82 | "eslint": "^4.19.1", 83 | "eslint-config-freecodecamp": "^1.1.1", 84 | "eslint-plugin-import": "^2.11.0", 85 | "eslint-plugin-prefer-object-spread": "^1.2.1", 86 | "eslint-plugin-react": "^7.7.0", 87 | "husky": "^0.14.3", 88 | "jest": "^23.0.0", 89 | "jest-cli": "^23.2.0", 90 | "lint-staged": "^7.0.4", 91 | "mongodb-memory-server": "^1.7.3", 92 | "nodemon": "^1.17.3", 93 | "prettier": "^1.11.1", 94 | "prettier-package-json": "^1.5.1", 95 | "serverless-plugin-aws-alerts": "^1.2.4", 96 | "serverless-plugin-notification-ojongerius": "^1.3.1", 97 | "sinon": "^4.5.0", 98 | "sinon-stub-promise": "^4.0.0", 99 | "source-map-support": "^0.5.4" 100 | }, 101 | "keywords": ["open-api"], 102 | "jest": { 103 | "transform": { 104 | "^.+\\.jsx?$": "babel-jest" 105 | }, 106 | "testEnvironment": "node", 107 | "testPathIgnorePatterns": ["/node_modules/", "./dist"], 108 | "coverageReporters": ["lcov", "html"], 109 | "coverageDirectory": "./coverage/", 110 | "collectCoverage": true, 111 | "moduleNameMapper": { 112 | "^mongoose$": "/node_modules/mongoose" 113 | } 114 | }, 115 | "lint-staged": { 116 | "*.{js,json,css}": ["prettier --write", "git add"], 117 | "package.json": ["prettier-package-json --write", "git add"] 118 | }, 119 | "snyk": true 120 | } 121 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | SERVERLESS_EMAIL_NOTIFICATIONS='' 2 | GITTER_WEBHOOK='' 3 | MONGODB_URL='mongodb://foo:bar@baz:41019/quuz' 4 | GRAPHQL_ENDPOINT_URL='/graphql' 5 | JWT_CERT='c2VjcmV0Cg=' 6 | 7 | AUTH0_NAMESPACE='https://.auth0.com' 8 | AUTH0_CLIENT_ID='its-me!' 9 | AUTH0_CLIENT_SECRET='Pa55w0rd!' 10 | -------------------------------------------------------------------------------- /scripts/base64encode.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | if (process.argv.length < 3) { 4 | console.log('Please provide filename and path to encode'); 5 | /* eslint-disable no-process-exit */ 6 | process.exit(1); 7 | /* eslint-enable no-process-exit */ 8 | } 9 | 10 | const cert = fs.readFileSync(process.argv[2]); 11 | 12 | console.log('Original contents: \n\n' + cert); 13 | console.log( 14 | 'Base64 encoded (copy this): \n\n' + Buffer.from(cert).toString('base64') 15 | ); 16 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | BRANCH=${TRAVIS_BRANCH:-$(git rev-parse --abbrev-ref HEAD)} 5 | 6 | if [[ $BRANCH == 'master' ]]; then 7 | STAGE="prod" 8 | export AUTH0_CLIENT_ID=$AUTH0_CLIENT_ID_PROD 9 | export AUTH0_CLIENT_SECRET=$AUTH0_CLIENT_SECRET_PROD 10 | export AUTH0_NAMESPACE=$AUTH0_NAMESPACE_PROD 11 | export GRAPHQL_ENDPOINT_URL=$GRAPHQL_ENDPOINT_URL_PROD 12 | export JWT_CERT=$JWT_CERT_PROD 13 | export MONGODB_URL=$MONGODB_URL_PROD 14 | elif [[ $BRANCH == 'staging' ]]; then 15 | STAGE="stage" 16 | export AUTH0_CLIENT_ID=$AUTH0_CLIENT_ID_STAGE 17 | export AUTH0_CLIENT_SECRET=$AUTH0_CLIENT_SECRET_STAGE 18 | export AUTH0_NAMESPACE=$AUTH0_NAMESPACE_STAGE 19 | export GRAPHQL_ENDPOINT_URL=$GRAPHQL_ENDPOINT_URL_STAGE 20 | export JWT_CERT=$JWT_CERT_STAGE 21 | export MONGODB_URL=$MONGODB_URL_STAGE 22 | fi 23 | 24 | if [ -z ${STAGE+x} ]; then 25 | echo "Only deploying for staging or production branches" 26 | exit 0 27 | fi 28 | 29 | if [[ $STAGE != 'stage' ]]; then 30 | echo "Only stage deployments for now, sorry!" 31 | exit 0 32 | fi 33 | 34 | 35 | echo "Deploying from branch $BRANCH to stage $STAGE" 36 | yarn prepare-production 37 | sls deploy --stage $STAGE --region $AWS_REGION 38 | -------------------------------------------------------------------------------- /scripts/generateHeader.js: -------------------------------------------------------------------------------- 1 | const secrets = require('../src/config/secrets.js'); 2 | const jwt = require('jsonwebtoken'); 3 | 4 | const jwtEncoded = secrets.getSecret().JWT_CERT; 5 | const JWT_CERT = Buffer.from(jwtEncoded, 'base64').toString('utf8'); 6 | 7 | const token = jwt.sign( 8 | { 9 | id: 123, 10 | name: 'Charlie', 11 | email: 'charlie@thebear.me' 12 | }, 13 | JWT_CERT 14 | ); 15 | const headers = '{"Authorization": "Bearer ' + token + '"}'; 16 | 17 | console.log(headers); 18 | -------------------------------------------------------------------------------- /serverless.yml: -------------------------------------------------------------------------------- 1 | service: open-api 2 | 3 | frameworkVersion: ">=1.21.0 <2.0.0" 4 | 5 | provider: 6 | name: aws 7 | runtime: nodejs8.10 8 | stage: dev 9 | region: us-east-1 10 | environment: 11 | AUTH0_CLIENT_ID: ${file(./src/config/secrets.js):getSecret.AUTH0_CLIENT_ID} 12 | AUTH0_CLIENT_SECRET: ${file(./src/config/secrets.js):getSecret.AUTH0_CLIENT_SECRET} 13 | AUTH0_NAMESPACE: ${file(./src/config/secrets.js):getSecret.AUTH0_NAMESPACE} 14 | GRAPHQL_ENDPOINT_URL: ${file(./src/config/secrets.js):getSecret.GRAPHQL_ENDPOINT_URL} 15 | JWT_CERT: ${file(./src/config/secrets.js):getSecret.JWT_CERT} 16 | MONGODB_URL: ${file(./src/config/secrets.js):getSecret.MONGODB_URL} 17 | 18 | plugins: 19 | - serverless-webpack 20 | - serverless-offline-scheduler 21 | - serverless-offline 22 | - serverless-plugin-notification-ojongerius 23 | - serverless-plugin-aws-alerts 24 | 25 | custom: 26 | serverless-offline: 27 | port: 4000 28 | webpack: 29 | webpackConfig: ./webpack.config.js 30 | includeModules: true 31 | notification: # serverless-plugin-notification-ojongerius 32 | webhook: 33 | url: ${env:GITTER_WEBHOOK} 34 | aws-notifications: 35 | - protocol: email 36 | endpoint: ${env:SERVERLESS_EMAIL_NOTIFICATIONS} 37 | alerts: 38 | stages: # Optionally - select which stages to deploy alarms to 39 | - prod 40 | - stage 41 | dashboards: true 42 | topics: 43 | alarm: 44 | topic: ${self:service}-${opt:stage}-alerts-alarm 45 | notifications: ${self:custom.aws-notifications} 46 | alarms: 47 | - functionThrottles 48 | - functionErrors 49 | - functionInvocations 50 | - functionDuration 51 | 52 | functions: 53 | graphql: 54 | handler: src/handler.graphqlHandler 55 | events: 56 | - http: 57 | path: graphql 58 | method: post 59 | cors: true 60 | 61 | api: 62 | handler: src/handler.apiHandler 63 | events: 64 | - http: 65 | path: api 66 | method: get 67 | cors: true 68 | -------------------------------------------------------------------------------- /src/_webpack/include.js: -------------------------------------------------------------------------------- 1 | // Add source map support for proper stack traces 2 | import 'source-map-support/register'; 3 | 4 | // Catch all unhandled exceptions and print their stack trace. 5 | // Required if the handler function is async, as promises 6 | // can swallow error messages. 7 | process.on('unhandledRejection', e => { 8 | console.error(e.stack); 9 | /* eslint-disable no-process-exit */ 10 | process.exit(1); 11 | /* eslint-enable no-process-exit */ 12 | }); 13 | -------------------------------------------------------------------------------- /src/auth/auth0.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import debug from 'debug'; 3 | 4 | import { asyncErrorHandler } from '../utils'; 5 | 6 | const log = debug('fcc:auth:auth0'); 7 | 8 | const { AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, AUTH0_NAMESPACE } = process.env; 9 | /* eslint-disable camelcase */ 10 | function getAPIToken() { 11 | log('requesting machine token'); 12 | const options = { 13 | method: 'POST', 14 | url: `${AUTH0_NAMESPACE}/oauth/token`, 15 | headers: { 'content-type': 'application/json' }, 16 | data: { 17 | client_id: AUTH0_CLIENT_ID, 18 | client_secret: AUTH0_CLIENT_SECRET, 19 | audience: `${AUTH0_NAMESPACE}/api/v2/`, 20 | grant_type: 'client_credentials' 21 | } 22 | }; 23 | 24 | return axios(options) 25 | .then(response => response.data.access_token) 26 | .catch(console.error); 27 | } 28 | 29 | export async function updateAppMetaData(id, update) { 30 | const body = { app_metadata: update }; 31 | /* eslint-enable camelcase */ 32 | const token = await asyncErrorHandler(getAPIToken()); 33 | const headers = { Authorization: `Bearer ${token}` }; 34 | 35 | const options = { 36 | method: 'PATCH', 37 | url: `${AUTH0_NAMESPACE}/api/v2/users/${id}`, 38 | headers, 39 | data: body 40 | }; 41 | log('setting app_metadata'); 42 | return axios(options).catch(console.error); 43 | } 44 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import debug from 'debug'; 3 | 4 | import { AuthorizationError } from '../graphql/errors'; 5 | 6 | const log = debug('fcc:auth'); 7 | const jwtEncoded = process.env.JWT_CERT; 8 | const JWT_CERT = Buffer.from(jwtEncoded, 'base64').toString('utf8'); 9 | 10 | export { updateAppMetaData } from './auth0'; 11 | 12 | export const namespace = 'https://auth-ns.freecodecamp.org/'; 13 | 14 | export const getTokenFromContext = ctx => 15 | ctx && 16 | ctx.headers && 17 | (ctx.headers.authorization || ctx.headers.Authorization); 18 | 19 | export function verifyWebToken(ctx) { 20 | log('Verifying token'); 21 | const token = getTokenFromContext(ctx); 22 | if (!token) { 23 | throw new AuthorizationError({ 24 | message: 25 | 'You must supply a JSON Web Token for authorization, are you logged in?' 26 | }); 27 | } 28 | let decoded = null; 29 | let error = null; 30 | try { 31 | decoded = jwt.verify(token.replace('Bearer ', ''), JWT_CERT); 32 | } catch (err) { 33 | error = err; 34 | } finally { 35 | return { decoded, error, isAuth: !!decoded }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/config/secrets.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const { 4 | MONGODB_URL, 5 | GRAPHQL_ENDPOINT_URL, 6 | JWT_CERT, 7 | AUTH0_CLIENT_ID, 8 | AUTH0_CLIENT_SECRET, 9 | AUTH0_NAMESPACE 10 | } = process.env; 11 | 12 | exports.getSecret = () => ({ 13 | AUTH0_CLIENT_ID, 14 | AUTH0_CLIENT_SECRET, 15 | AUTH0_NAMESPACE, 16 | GRAPHQL_ENDPOINT_URL, 17 | JWT_CERT, 18 | MONGODB_URL 19 | }); 20 | -------------------------------------------------------------------------------- /src/dataLayer/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // Only reconnect if needed. State is saved and outlives a handler invocation 4 | let isConnected; 5 | 6 | const connectToDatabase = () => { 7 | if (isConnected) { 8 | console.log('Re-using existing database connection'); 9 | return Promise.resolve(); 10 | } 11 | 12 | console.log('Creating new database connection'); 13 | return mongoose.connect(process.env.MONGODB_URL).then(db => { 14 | isConnected = db.connections[0].readyState; 15 | }); 16 | }; 17 | 18 | module.exports = connectToDatabase; 19 | -------------------------------------------------------------------------------- /src/dataLayer/model/communityEvent.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const communityEventSchema = new Schema({ 6 | externalId: { 7 | type: 'string', 8 | description: 'Unique ID to refers to events externally', 9 | required: true 10 | }, 11 | title: { 12 | type: 'string', 13 | required: true 14 | }, 15 | description: { 16 | type: 'string', 17 | required: true 18 | }, 19 | owner: { 20 | type: mongoose.Schema.ObjectId, 21 | ref: 'User', 22 | description: 'ID of owner of event', 23 | required: true 24 | }, 25 | // Many to many relationship, see 26 | // http://mongoosejs.com/docs/populate.html 27 | attendees: [ 28 | { 29 | type: mongoose.Schema.ObjectId, 30 | ref: 'User', 31 | description: 'Attendee' 32 | } 33 | ], 34 | 35 | date: { 36 | type: 'date', 37 | required: true 38 | }, 39 | imageUrl: { 40 | type: 'string' 41 | }, 42 | isLocked: { 43 | type: 'boolean', 44 | description: 'Event is locked', 45 | default: true 46 | } 47 | }); 48 | 49 | module.exports = mongoose.model( 50 | 'CommunityEvent', 51 | communityEventSchema, 52 | 'communityEvent' 53 | ); 54 | -------------------------------------------------------------------------------- /src/dataLayer/model/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const userSchema = new Schema({ 6 | externalId: { 7 | type: 'string', 8 | description: 'A UUID that is communicated externally' 9 | }, 10 | accountLinkId: { 11 | type: 'string', 12 | description: 'A uuid used to link SSO and freeCodeCamp accounts together', 13 | required: true 14 | }, 15 | email: { 16 | type: 'string' 17 | }, 18 | isCheater: { 19 | type: 'boolean', 20 | description: 21 | 'Users who are confirmed to have broken academic honesty policy are ' + 22 | 'marked as cheaters', 23 | default: false 24 | }, 25 | username: { 26 | type: 'string' 27 | }, 28 | name: { 29 | type: 'string', 30 | default: '' 31 | }, 32 | sendQuincyEmail: { 33 | type: 'boolean', 34 | default: true 35 | }, 36 | isLocked: { 37 | type: 'boolean', 38 | description: 39 | 'Campers profile does not show challenges/certificates to the public', 40 | default: true 41 | }, 42 | currentChallengeId: { 43 | type: 'string', 44 | description: 'The challenge last visited by the user', 45 | default: '' 46 | }, 47 | isHonest: { 48 | type: 'boolean', 49 | description: 'Camper has signed academic honesty policy', 50 | default: false 51 | }, 52 | isFrontEndCert: { 53 | type: 'boolean', 54 | description: 'Camper is front end certified', 55 | default: false 56 | }, 57 | isDataVisCert: { 58 | type: 'boolean', 59 | description: 'Camper is data visualization certified', 60 | default: false 61 | }, 62 | isBackEndCert: { 63 | type: 'boolean', 64 | description: 'Campers is back end certified', 65 | default: false 66 | }, 67 | isFullStackCert: { 68 | type: 'boolean', 69 | description: 'Campers is full stack certified', 70 | default: false 71 | }, 72 | isRespWebDesignCert: { 73 | type: 'boolean', 74 | description: 'Camper is responsive web design certified', 75 | default: false 76 | }, 77 | is2018DataVisCert: { 78 | type: 'boolean', 79 | description: 'Camper is data visualization certified (2018)', 80 | default: false 81 | }, 82 | isFrontEndLibsCert: { 83 | type: 'boolean', 84 | description: 'Camper is front end libraries certified', 85 | default: false 86 | }, 87 | isJsAlgoDataStructCert: { 88 | type: 'boolean', 89 | description: 90 | 'Camper is javascript algorithms and data structures certified', 91 | default: false 92 | }, 93 | isApisMicroservicesCert: { 94 | type: 'boolean', 95 | description: 'Camper is apis and microservices certified', 96 | default: false 97 | }, 98 | isInfosecQaCert: { 99 | type: 'boolean', 100 | description: 101 | 'Camper is information security and quality assurance certified', 102 | default: false 103 | }, 104 | completedChallenges: { 105 | type: [ 106 | { 107 | completedDate: 'number', 108 | id: 'string', 109 | solution: 'string', 110 | githubLink: 'string' 111 | } 112 | ], 113 | default: [] 114 | }, 115 | theme: { 116 | type: 'string', 117 | default: 'default' 118 | }, 119 | // Many to many relationship, see 120 | // http://mongoosejs.com/docs/populate.html 121 | events: [ 122 | { 123 | type: mongoose.Schema.ObjectId, 124 | ref: 'Event', 125 | description: 'Event' 126 | } 127 | ] 128 | }); 129 | 130 | module.exports = mongoose.model('User', userSchema, 'user'); 131 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/__snapshots__/event-datalayer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createCommunityEvent should throw if an attendee does yet exist 1`] = `"Unable to find attendee: {\\"email\\":\\"yeahnah@example.com\\"}"`; 4 | 5 | exports[`createCommunityEvent should throw if an imageUrl is not a valid URL 1`] = `"Expected valid URL string, got \\"notaUrl\\""`; 6 | 7 | exports[`createCommunityEvent should throw if an isLocked is not a Boolean 1`] = `"Expected a Boolean value, got \\"notaBoolean\\""`; 8 | 9 | exports[`deleteCommunityEvent should return with an error for a non existing event 1`] = `[Error: Event not found]`; 10 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/__snapshots__/user-dataLayer.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createUser should throw if accountLinkId is already in db 1`] = `[Error: Account already in use]`; 4 | 5 | exports[`deleteUser should refuse deletion of other users 1`] = `[Error: You can delete only your account]`; 6 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/communityEvent.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import CommunityEventModel from '../model/communityEvent.js'; 3 | import UserModel from '../model/user.js'; 4 | import debug from 'debug'; 5 | import uuid from 'uuid/v4'; 6 | import { isEmpty, isString } from 'lodash'; 7 | 8 | import { asyncErrorHandler } from '../../utils'; 9 | import { verifyWebToken } from '../../auth'; 10 | import { isArray } from 'util'; 11 | 12 | const log = debug('fcc:dataLayer:mongo:event'); 13 | 14 | function resolveAttendees(attendees) { 15 | return new Promise(async function resolveAttendeesPromise(resolve, reject) { 16 | if (!isArray(attendees)) { 17 | reject(new TypeError('Expected list of attendees')); 18 | return null; 19 | } 20 | 21 | const resolvedAttendeesResult = []; 22 | for (const attendeeUuid of attendees) { 23 | const attendee = await UserModel.findOne(attendeeUuid, '_id'); 24 | 25 | if (attendee && !attendee.isEmpty) { 26 | resolvedAttendeesResult.push(attendee._id); 27 | } else { 28 | reject(`Unable to find attendee: ${JSON.stringify(attendeeUuid)}`); 29 | return null; 30 | } 31 | } 32 | 33 | resolve(resolvedAttendeesResult); 34 | }); 35 | } 36 | 37 | async function resolveAndPopulateEvent(query) { 38 | return CommunityEventModel.findOne(query) 39 | .populate('owner', 'name email') 40 | .populate('attendees', 'name email') 41 | .exec(); 42 | } 43 | 44 | function validateQuery(input) { 45 | return ( 46 | ('externalId' in input && 47 | (isString(input.externalId) && validator.isUUID(input.externalId))) || 48 | ('title' in input && isString(input.title)) 49 | ); 50 | } 51 | 52 | export async function getCommunityEvent(root, vars) { 53 | return new Promise(async function getCommunityEventPromise(resolve, reject) { 54 | if (!('externalId' in vars) && !('title' in vars)) { 55 | reject(new TypeError('Expected an event ID or title')); 56 | return null; 57 | } 58 | 59 | const query = 60 | 'externalId' in vars 61 | ? { externalId: vars.externalId } 62 | : { 63 | title: vars.title 64 | }; 65 | 66 | if (!validateQuery(query)) { 67 | reject(new TypeError('Expected a valid externalId or title')); 68 | return null; 69 | } 70 | 71 | log(`Query: ${JSON.stringify(query)}`); 72 | const event = await resolveAndPopulateEvent(query); 73 | if (isEmpty(event)) { 74 | return resolve(null); 75 | } 76 | 77 | return resolve(event); 78 | }); 79 | } 80 | 81 | function resolveAndPopulateEvents(query) { 82 | return CommunityEventModel.find(query) 83 | .populate('owner', 'name email') 84 | .populate('attendees', 'name email') 85 | .exec(); 86 | } 87 | 88 | export async function getCommunityEvents(root, vars) { 89 | return new Promise(async function getCommunityEventsPromise(resolve, reject) { 90 | if (!('externalId' in vars) && !('title' in vars)) { 91 | reject(new TypeError('Expected an event ID or title')); 92 | return null; 93 | } 94 | 95 | const query = 96 | 'externalId' in vars 97 | ? { externalId: vars.externalId } 98 | : { 99 | title: vars.title 100 | }; 101 | 102 | if (!validateQuery(query)) { 103 | reject(new TypeError('Expected a valid externalId or title')); 104 | return null; 105 | } 106 | 107 | log(`Query: ${JSON.stringify(query)}`); 108 | const events = await asyncErrorHandler(resolveAndPopulateEvents(query)); 109 | 110 | if (isEmpty(events)) { 111 | return resolve(null); 112 | } 113 | 114 | return resolve(events); 115 | }); 116 | } 117 | 118 | export async function validatedRequestor(context) { 119 | return new Promise(async function validatedRequestorPromise(resolve, reject) { 120 | const { decoded } = verifyWebToken(context); 121 | const { email } = decoded; 122 | 123 | if (!isString(email) || !validator.isEmail(email)) { 124 | reject(new Error('You must provide a valid email')); 125 | return null; 126 | } 127 | 128 | const user = await UserModel.findOne({ email: email }, '_id') 129 | .exec() 130 | .catch(err => { 131 | if (err) { 132 | log(`Error finding user: ${JSON.stringify(err)}`); 133 | reject(new Error('Something went wrong querying database')); 134 | return null; 135 | } 136 | }); 137 | 138 | if (user.isEmpty) { 139 | log( 140 | `Unable to resolve user making request for email: ${JSON.stringify( 141 | email 142 | )}` 143 | ); 144 | reject(new Error('Cannot resolve user of requestor')); 145 | return null; 146 | } 147 | 148 | return resolve(user); 149 | }); 150 | } 151 | 152 | function validateAttendees(attendees) { 153 | return new Promise(async function validateAttendeesPromise(resolve, reject) { 154 | for (const attendee of attendees) { 155 | if ('externalId' in attendee && !validator.isUUID(attendee.externalId)) { 156 | reject( 157 | new Error( 158 | `Expected a valid externalId, got ${JSON.stringify( 159 | attendee.externalId 160 | )}` 161 | ) 162 | ); 163 | return null; 164 | } else if ('email' in attendee && !validator.isEmail(attendee.email)) { 165 | reject( 166 | new Error( 167 | `Expected a valid email address, got ${JSON.stringify( 168 | attendee.email 169 | )}` 170 | ) 171 | ); 172 | return null; 173 | } 174 | } 175 | resolve(); 176 | }); 177 | } 178 | 179 | export async function createCommunityEvent(root, vars, ctx) { 180 | return new Promise(async function createCommunityEventPromise( 181 | resolve, 182 | reject 183 | ) { 184 | const user = await validatedRequestor(ctx).catch(err => { 185 | if (err) { 186 | reject(err); 187 | return null; 188 | } 189 | }); 190 | 191 | if (user === null) { 192 | return null; 193 | } 194 | 195 | const newEvent = { 196 | title: vars.title, 197 | description: vars.description, 198 | owner: user.id, 199 | date: vars.date, 200 | externalId: uuid() 201 | }; 202 | 203 | if ('imageUrl' in vars) { 204 | if (!isString(vars.imageUrl) || !validator.isURL(vars.imageUrl)) { 205 | reject( 206 | `Expected valid URL string, got ${JSON.stringify(vars.imageUrl)}` 207 | ); 208 | return null; 209 | } 210 | 211 | newEvent.imageUrl = vars.imageUrl; 212 | } 213 | 214 | if ('isLocked' in vars) { 215 | if (!isString(vars.isLocked) || !validator.isBoolean(vars.isLocked)) { 216 | reject( 217 | `Expected a Boolean value, got ${JSON.stringify(vars.isLocked)}` 218 | ); 219 | return null; 220 | } 221 | 222 | newEvent.isLocked = vars.isLocked; 223 | } 224 | 225 | if ('attendees' in vars) { 226 | await validateAttendees(vars.attendees).catch(err => reject(err)); 227 | newEvent.attendees = await resolveAttendees(vars.attendees).catch(err => { 228 | reject(err); 229 | return null; 230 | }); 231 | } 232 | 233 | // TODO: populate object with what is there, and validate against a schema 234 | const event = new CommunityEventModel(newEvent); 235 | 236 | return await event.save(err => { 237 | if (err) { 238 | reject(err); 239 | return null; 240 | } 241 | /* Will have attendees populated at this point 242 | Note that it interestingly this will *not* have an ID: 243 | { 244 | "title":"another new event", 245 | "description":"cool event IV", 246 | "owner":"5ad1c7ace769e064e3a3487b", 247 | "date":"Mon 28 May 2018 13:28:28", 248 | "externalId":"2c959437-34a6-43f8-9718-b800b5a0dcea", 249 | "attendees":["5ad1c7ace769e064e3a3487b"] 250 | } 251 | 252 | Hence well use our external ID: 253 | */ 254 | 255 | return resolve( 256 | resolveAndPopulateEvent({ externalId: newEvent.externalId }) 257 | ); 258 | }); 259 | }); 260 | } 261 | 262 | // TODO: Implement an update function 263 | // export async function updateEvent(root, vars, ctx) { 264 | // } 265 | 266 | // Delete event, should only be allowed by creator and / or admin 267 | export async function deleteCommunityEvent(root, vars, ctx) { 268 | const user = await validatedRequestor(ctx).catch(err => { 269 | if (err) { 270 | throw new Error(err); 271 | } 272 | }); 273 | 274 | if ('externalId' in vars) { 275 | if (!isString(vars.externalId) || !validator.isUUID(vars.externalId)) { 276 | throw new TypeError('Not a valid UUID'); 277 | } 278 | } 279 | 280 | await CommunityEventModel.findOne({ 281 | externalId: vars.externalId 282 | }) 283 | .exec() 284 | .then((event, err) => { 285 | if (err) { 286 | throw err; 287 | } 288 | 289 | if (isEmpty(event)) { 290 | throw new Error('Event not found'); 291 | } 292 | 293 | if (event.owner.externalId !== user.externalId) { 294 | throw new Error('Only allowed to delete events you own'); 295 | } 296 | }); 297 | 298 | return await CommunityEventModel.findOneAndRemove({ 299 | externalId: vars.externalId 300 | }).then((event, err) => { 301 | if (err) { 302 | log(`Error find and removing document: ${JSON.stringify(err)}`); 303 | throw new Error('Something went wrong querying database'); 304 | } 305 | 306 | if (!event) { 307 | throw new Error(`No event with externalId ${vars.externalId}`); 308 | } 309 | return event; 310 | }); 311 | } 312 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/event-datalayer.test.js: -------------------------------------------------------------------------------- 1 | /* global expect beforeAll afterAll */ 2 | import { 3 | createCommunityEvent, 4 | getCommunityEvent, 5 | getCommunityEvents, 6 | deleteCommunityEvent 7 | } from './communityEvent'; 8 | import { createUser } from './user'; 9 | import CommunityEvent from '../model/communityEvent.js'; 10 | import { isObject, isEmpty } from 'lodash'; 11 | import mongoose from 'mongoose'; 12 | import uuid from 'uuid/v4'; 13 | import { isDate, isArray } from 'util'; 14 | 15 | const validContextForBrian = global.mockedContextWithValidTokenForBrian; 16 | const validContextForDennis = global.mockedContextWithValidTokenForDennis; 17 | const validContextForKen = global.mockedContextWithValidTokenForKen; 18 | 19 | const event = { 20 | title: 'epoch', 21 | description: 'The start of POSIX time', 22 | date: 'Thu 1 Jan 1970 00:00:00' 23 | }; 24 | 25 | const eventValidAttendees = { 26 | ...event, 27 | attendees: [ 28 | { email: 'dennisritchie@example.com' }, 29 | { email: 'kenthompson@example.com' } 30 | ] 31 | }; 32 | 33 | const eventInValidAttendees = { 34 | ...event, 35 | attendees: [{ email: 'yeahnah@example.com' }] 36 | }; 37 | 38 | beforeAll(async function beforeAllTests() { 39 | await mongoose.connect(global.__MONGO_URI__); 40 | // Create some users for our tests 41 | await createUser({}, {}, validContextForBrian); 42 | await createUser({}, {}, validContextForDennis); 43 | await createUser({}, {}, validContextForKen); 44 | 45 | // Create an event 46 | await createCommunityEvent({}, eventValidAttendees, validContextForBrian); 47 | }); 48 | 49 | afterAll(async function afterAllTests() { 50 | await mongoose.connection.db.dropDatabase(); 51 | await mongoose.disconnect(); 52 | }); 53 | 54 | describe('createCommunityEvent', () => { 55 | it('should return an Event object', done => { 56 | expect.assertions(7); 57 | createCommunityEvent({}, eventValidAttendees, validContextForBrian) 58 | .then(result => { 59 | const { 60 | externalId, 61 | title, 62 | description, 63 | owner, 64 | date, 65 | attendees 66 | } = result; 67 | // there is some weird Promise thing going on with `result` 68 | // which screws with lodash.has() 69 | 70 | // TODO: DRY this please 71 | const hasKeys = 72 | !isEmpty(externalId) && 73 | !isEmpty(title) && 74 | !isEmpty(description) && 75 | !isEmpty(owner) && 76 | isDate(date) && 77 | isArray(attendees); 78 | 79 | const hasOwnerKeys = !isEmpty(owner.name) && !isEmpty(owner.email); 80 | const hasAttendeeKeys = 81 | !isEmpty(attendees[0].name) && !isEmpty(attendees[0].email); 82 | 83 | expect(isObject(result)).toBe(true); 84 | expect(isObject(result.owner)).toBe(true); 85 | expect(isObject(result.attendees[0])).toBe(true); 86 | expect(result.attendees).toHaveLength(2); 87 | expect(hasKeys).toBe(true); 88 | expect(hasOwnerKeys).toBe(true); 89 | expect(hasAttendeeKeys).toBe(true); 90 | return; 91 | }) 92 | .then(done) 93 | .catch(done); 94 | }); 95 | 96 | it('should throw if an attendee does yet exist', done => { 97 | expect.assertions(2); 98 | createCommunityEvent({}, eventInValidAttendees, validContextForBrian).catch( 99 | err => { 100 | expect(err).toMatchSnapshot(); 101 | expect(err).toContain('Unable to find attendee'); 102 | done(); 103 | return; 104 | } 105 | ); 106 | }); 107 | 108 | it('should throw if an imageUrl is not a valid URL', done => { 109 | expect.assertions(); 110 | createCommunityEvent( 111 | {}, 112 | { 113 | ...event, 114 | imageUrl: 'notaUrl' 115 | }, 116 | validContextForBrian 117 | ).catch(err => { 118 | expect(err).toMatchSnapshot(); 119 | expect(err).toContain('Expected valid URL string'); 120 | done(); 121 | return; 122 | }); 123 | }); 124 | 125 | it('should throw if an isLocked is not a Boolean', done => { 126 | expect.assertions(2); 127 | createCommunityEvent( 128 | {}, 129 | { 130 | ...event, 131 | isLocked: 'notaBoolean' 132 | }, 133 | validContextForBrian 134 | ).catch(err => { 135 | expect(err).toMatchSnapshot(); 136 | expect(err).toContain('Expected a Boolean value'); 137 | done(); 138 | return; 139 | }); 140 | }); 141 | }); 142 | 143 | describe('getCommunityEvent', () => { 144 | it('should return an Event object for a valid request', done => { 145 | expect.assertions(7); 146 | getCommunityEvent({}, { title: 'epoch' }, validContextForBrian) 147 | .then(result => { 148 | const { 149 | externalId, 150 | title, 151 | description, 152 | owner, 153 | date, 154 | attendees 155 | } = result; 156 | // there is some weird Promise thing going on with `result` 157 | // which screws with lodash.has() 158 | const hasKeys = 159 | !isEmpty(externalId) && 160 | !isEmpty(title) && 161 | !isEmpty(description) && 162 | !isEmpty(owner) && 163 | isDate(date) && 164 | isArray(attendees); 165 | 166 | const hasOwnerKeys = !isEmpty(owner.name) && !isEmpty(owner.email); 167 | const hasAttendeeKeys = 168 | !isEmpty(attendees[0].name) && !isEmpty(attendees[0].email); 169 | 170 | expect(isObject(result)).toBe(true); 171 | expect(isObject(owner)).toBe(true); 172 | expect(isObject(attendees[0])).toBe(true); 173 | expect(attendees).toHaveLength(2); 174 | expect(hasKeys).toBe(true); 175 | expect(hasOwnerKeys).toBe(true); 176 | expect(hasAttendeeKeys).toBe(true); 177 | return; 178 | }) 179 | .then(done) 180 | .catch(done); 181 | }); 182 | 183 | it('title search non existing event should return null', done => { 184 | expect.assertions(1); 185 | getCommunityEvent({}, { title: 'Yeah nah' }, validContextForBrian).then( 186 | data => { 187 | expect(data).toBe(null); 188 | done(); 189 | } 190 | ); 191 | }); 192 | 193 | it('externalId search for non existing event should return null', done => { 194 | expect.assertions(1); 195 | getCommunityEvent({}, { externalId: uuid() }, validContextForBrian).then( 196 | data => { 197 | expect(data).toBe(null); 198 | done(); 199 | } 200 | ); 201 | }); 202 | 203 | // function validateError(err) { 204 | // expect(err).toBeInstanceOf(TypeError); 205 | // expect(err.message).toContain('Expected a valid externalId or title'); 206 | // } 207 | 208 | function getCommunityEventPromiseForTestCase(testCase) { 209 | return getCommunityEvent( 210 | {}, 211 | { externalId: testCase }, 212 | validContextForBrian 213 | ).catch(err => { 214 | expect(err).toBeInstanceOf(TypeError); 215 | expect(err.message).toContain('Expected a valid externalId or title'); 216 | }); 217 | } 218 | 219 | // TODO: DRY please 220 | it('should throw if the externalId is not valid', done => { 221 | expect.assertions(8); 222 | Promise.all([ 223 | getCommunityEventPromiseForTestCase(1), 224 | getCommunityEventPromiseForTestCase('abc'), 225 | getCommunityEventPromiseForTestCase(['yeah nah']), 226 | getCommunityEventPromiseForTestCase(false) 227 | ]).then(() => { 228 | done(); 229 | }); 230 | }); 231 | 232 | it('should throw if the title is not valid', done => { 233 | expect.assertions(6); 234 | Promise.all([ 235 | getCommunityEvent({}, { title: 1 }, validContextForBrian).catch(err => { 236 | expect(err).toBeInstanceOf(TypeError); 237 | expect(err.message).toContain('Expected a valid externalId or title'); 238 | }), 239 | getCommunityEvent( 240 | {}, 241 | { title: ['yeah nah'] }, 242 | validContextForBrian 243 | ).catch(err => { 244 | expect(err).toBeInstanceOf(TypeError); 245 | expect(err.message).toContain('Expected a valid externalId or title'); 246 | }), 247 | getCommunityEvent({}, { title: false }, validContextForBrian).catch( 248 | err => { 249 | expect(err).toBeInstanceOf(TypeError); 250 | expect(err.message).toContain('Expected a valid externalId or title'); 251 | } 252 | ) 253 | ]).then(() => { 254 | done(); 255 | }); 256 | }); 257 | }); 258 | 259 | describe('getCommunityEvents', () => { 260 | it('should return multiple Event objects for a valid request', done => { 261 | expect.assertions(8); 262 | getCommunityEvents({}, { title: 'epoch' }, validContextForBrian) 263 | .then(result => { 264 | const { 265 | externalId, 266 | title, 267 | description, 268 | owner, 269 | date, 270 | attendees 271 | } = result[0]; 272 | 273 | // there is some weird Promise thing going on with `result` 274 | // which screws with lodash.has() 275 | const hasKeys = 276 | !isEmpty(externalId) && 277 | !isEmpty(title) && 278 | !isEmpty(description) && 279 | !isEmpty(owner) && 280 | isDate(date) && 281 | isArray(attendees); 282 | 283 | const hasOwnerKeys = !isEmpty(owner.name) && !isEmpty(owner.email); 284 | const hasAttendeeKeys = 285 | !isEmpty(attendees[0].name) && !isEmpty(attendees[0].email); 286 | 287 | // TODO: 2 depend on other tests to create events for us, 288 | // this is a bit brittle.. 289 | expect(result).toHaveLength(3); 290 | expect(isObject(result)).toBe(true); 291 | expect(isObject(owner)).toBe(true); 292 | expect(isObject(attendees[0])).toBe(true); 293 | expect(attendees).toHaveLength(2); 294 | expect(hasKeys).toBe(true); 295 | expect(hasOwnerKeys).toBe(true); 296 | expect(hasAttendeeKeys).toBe(true); 297 | return; 298 | }) 299 | .then(done) 300 | .catch(done); 301 | }); 302 | 303 | it('title search non existing event should return null', done => { 304 | expect.assertions(1); 305 | getCommunityEvents({}, { title: 'Yeah nah' }, validContextForBrian).then( 306 | data => { 307 | expect(data).toBe(null); 308 | done(); 309 | } 310 | ); 311 | }); 312 | 313 | it('externalId search for non existing event should return null', done => { 314 | expect.assertions(1); 315 | getCommunityEvents({}, { externalId: uuid() }, validContextForBrian).then( 316 | data => { 317 | expect(data).toBe(null); 318 | done(); 319 | } 320 | ); 321 | }); 322 | 323 | // TODO: DRY please 324 | it('should throw if the externalId is not valid', done => { 325 | expect.assertions(8); 326 | Promise.all([ 327 | getCommunityEvents({}, { externalId: 1 }, validContextForBrian).catch( 328 | err => { 329 | expect(err).toBeInstanceOf(TypeError); 330 | expect(err.message).toContain('Expected a valid externalId or title'); 331 | } 332 | ), 333 | getCommunityEvents({}, { externalId: 'abc' }, validContextForBrian).catch( 334 | err => { 335 | expect(err).toBeInstanceOf(TypeError); 336 | expect(err.message).toContain('Expected a valid externalId or title'); 337 | } 338 | ), 339 | getCommunityEvents( 340 | {}, 341 | { externalId: ['yeah nah'] }, 342 | validContextForBrian 343 | ).catch(err => { 344 | expect(err).toBeInstanceOf(TypeError); 345 | expect(err.message).toContain('Expected a valid externalId or title'); 346 | }), 347 | getCommunityEvents({}, { externalId: false }, validContextForBrian).catch( 348 | err => { 349 | expect(err).toBeInstanceOf(TypeError); 350 | expect(err.message).toContain('Expected a valid externalId or title'); 351 | } 352 | ) 353 | ]).then(() => { 354 | done(); 355 | }); 356 | }); 357 | 358 | it('should throw if the title is not valid', done => { 359 | expect.assertions(6); 360 | Promise.all([ 361 | getCommunityEvents({}, { title: 1 }, validContextForBrian).catch(err => { 362 | expect(err).toBeInstanceOf(TypeError); 363 | expect(err.message).toContain('Expected a valid externalId or title'); 364 | }), 365 | getCommunityEvents( 366 | {}, 367 | { title: ['yeah nah'] }, 368 | validContextForBrian 369 | ).catch(err => { 370 | expect(err).toBeInstanceOf(TypeError); 371 | expect(err.message).toContain('Expected a valid externalId or title'); 372 | }), 373 | getCommunityEvents({}, { title: false }, validContextForBrian).catch( 374 | err => { 375 | expect(err).toBeInstanceOf(TypeError); 376 | expect(err.message).toContain('Expected a valid externalId or title'); 377 | } 378 | ) 379 | ]).then(() => { 380 | done(); 381 | }); 382 | }); 383 | }); 384 | 385 | describe('deleteCommunityEvent', () => { 386 | it('should delete an existing event', async done => { 387 | const event = { 388 | title: 'deleteCommunityEvent event', 389 | description: 'A boring test event', 390 | date: 'Thu 1 Jan 1970 00:00:00' 391 | }; 392 | 393 | const createdEvent = await createCommunityEvent( 394 | {}, 395 | event, 396 | validContextForBrian 397 | ); 398 | const deletedEvent = await deleteCommunityEvent( 399 | {}, 400 | { externalId: createdEvent.externalId }, 401 | validContextForBrian 402 | ); 403 | 404 | expect(createdEvent.externalId).toMatch(deletedEvent.externalId); 405 | 406 | const foundEvent = await CommunityEvent.findOne({ 407 | externalId: deletedEvent.externalId 408 | }).exec(); 409 | expect(foundEvent).toBe(null); 410 | done(); 411 | }); 412 | 413 | it('should return with an error for a non existing event', async done => { 414 | try { 415 | await deleteCommunityEvent( 416 | {}, 417 | { externalId: uuid() }, 418 | validContextForBrian 419 | ); 420 | } catch (err) { 421 | expect(err).toBeInstanceOf(Error); 422 | expect(err.message).toContain('Event not found'); 423 | expect(err).toMatchSnapshot(); 424 | } 425 | done(); 426 | }); 427 | 428 | it('should refuse deletion of events owned by other users', async done => { 429 | const e = await CommunityEvent.findOne({ title: 'epoch' }).exec(); 430 | try { 431 | await deleteCommunityEvent( 432 | {}, 433 | { externalId: e.externalId }, 434 | validContextForKen 435 | ); 436 | } catch (err) { 437 | expect(err).toBeInstanceOf(Error); 438 | expect(err.message).toContain('Only allowed to delete events you own'); 439 | expect(err).toMatchSnapshot(); 440 | } 441 | done(); 442 | }); 443 | }); 444 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/user-dataLayer.test.js: -------------------------------------------------------------------------------- 1 | /* global expect beforeAll afterAll */ 2 | import { createUser, getUser, deleteUser } from './user'; 3 | import UserModel from '../model/user.js'; 4 | import { isObject, isEmpty } from 'lodash'; 5 | import mongoose from 'mongoose'; 6 | 7 | const validContextForCharlie = global.mockedContextWithValidTokenForCharlie; 8 | const validContextForLola = global.mockedContextWithValidTokenForLola; 9 | const validContextForJane = global.mockedContextWithValidTokenForJane; 10 | 11 | beforeAll(async function beforeAllTests() { 12 | await mongoose.connect(global.__MONGO_URI__); 13 | }); 14 | 15 | afterAll(async function afterAllTests() { 16 | await mongoose.disconnect(); 17 | }); 18 | 19 | describe('createUser', () => { 20 | it('should return a User object', done => { 21 | expect.assertions(2); 22 | createUser({}, {}, validContextForLola) 23 | .then(result => { 24 | const { name, email, accountLinkId } = result; 25 | // there is some weird Promise thing going on with `result` 26 | // which screws with lodash.has() 27 | const hasKeys = 28 | !isEmpty(name) && !isEmpty(email) && !isEmpty(accountLinkId); 29 | 30 | expect(isObject(result)).toBe(true); 31 | expect(hasKeys).toBe(true); 32 | return; 33 | }) 34 | .then(done) 35 | .catch(done); 36 | }); 37 | 38 | it('should throw if accountLinkId is already in db', done => { 39 | expect.assertions(1); 40 | createUser({}, {}, validContextForLola).catch(err => { 41 | expect(err).toMatchSnapshot(); 42 | done(); 43 | return; 44 | }); 45 | }); 46 | }); 47 | 48 | describe('getUser', () => { 49 | it('should return a User object fo a valid request', done => { 50 | expect.assertions(2); 51 | const email = 'lola@cbbc.tv'; 52 | getUser({}, { email }, validContextForLola) 53 | .then(result => { 54 | const { name, email, accountLinkId } = result; 55 | // there is some weird Promise thing going on with `result` 56 | // which screws with lodash.has() 57 | const hasKeys = 58 | !isEmpty(name) && !isEmpty(email) && !isEmpty(accountLinkId); 59 | 60 | expect(isObject(result)).toBe(true); 61 | expect(hasKeys).toBe(true); 62 | return; 63 | }) 64 | .then(done) 65 | .catch(done); 66 | }); 67 | 68 | it('should throw for a user not found', done => { 69 | expect.assertions(1); 70 | const email = 'not@inthe.db'; 71 | getUser({}, { email }, validContextForCharlie).catch(err => { 72 | expect(err.message).toContain('No user found for '); 73 | done(); 74 | }); 75 | }); 76 | 77 | it('should throw if the supplied email is not valid', done => { 78 | expect.assertions(15); 79 | Promise.all([ 80 | getUser({}, { email: 1 }, validContextForCharlie).catch(err => { 81 | expect(err).toBeInstanceOf(TypeError); 82 | expect(err.message).toContain('Expected a valid email'); 83 | expect(err.message).toContain('1'); 84 | }), 85 | getUser({}, { email: 'not an email' }, validContextForCharlie).catch( 86 | err => { 87 | expect(err).toBeInstanceOf(TypeError); 88 | expect(err.message).toContain('Expected a valid email'); 89 | expect(err.message).toContain('not an email'); 90 | } 91 | ), 92 | getUser({}, { email: ['nope'] }, validContextForCharlie).catch(err => { 93 | expect(err).toBeInstanceOf(TypeError); 94 | expect(err.message).toContain('Expected a valid email'); 95 | expect(err.message).toContain('["nope"]'); 96 | }), 97 | getUser({}, { email: { email: false } }, validContextForCharlie).catch( 98 | err => { 99 | expect(err).toBeInstanceOf(TypeError); 100 | expect(err.message).toContain('Expected a valid email'); 101 | expect(err.message).toContain('false'); 102 | } 103 | ), 104 | getUser({}, { email: 1 }, validContextForCharlie).catch(err => { 105 | expect(err).toBeInstanceOf(TypeError); 106 | expect(err.message).toContain('Expected a valid email'); 107 | expect(err.message).toContain('1'); 108 | }) 109 | ]).then(() => { 110 | done(); 111 | }); 112 | }); 113 | }); 114 | 115 | describe('deleteUser', () => { 116 | it('should delete an existing user', async done => { 117 | const result = await createUser({}, {}, validContextForJane); 118 | const { accountLinkId } = result; 119 | const response = await deleteUser( 120 | {}, 121 | { accountLinkId }, 122 | validContextForJane 123 | ); 124 | expect(response).toBeTruthy(); 125 | expect(response.accountLinkId).toMatch(accountLinkId); 126 | const searchResult = await UserModel.findOne({ accountLinkId }); 127 | expect(searchResult).toBe(null); 128 | done(); 129 | }); 130 | it('should return with an error for a non existing user', async done => { 131 | try { 132 | await deleteUser( 133 | {}, 134 | { accountLinkId: global.idOfLola }, 135 | validContextForLola 136 | ); 137 | } catch (err) { 138 | expect(err).toMatchSnapshot(); 139 | } 140 | done(); 141 | }); 142 | it('should refuse deletion of other users', async done => { 143 | try { 144 | await deleteUser( 145 | {}, 146 | { accountLinkId: global.idOfLola }, 147 | validContextForCharlie 148 | ); 149 | } catch (err) { 150 | expect(err).toMatchSnapshot(); 151 | } 152 | done(); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/dataLayer/mongo/user.js: -------------------------------------------------------------------------------- 1 | import validator from 'validator'; 2 | import UserModel from '../model/user.js'; 3 | import debug from 'debug'; 4 | import uuid from 'uuid/v4'; 5 | import { isEmpty, isString } from 'lodash'; 6 | 7 | import { asyncErrorHandler } from '../../utils'; 8 | import { verifyWebToken, namespace, updateAppMetaData } from '../../auth'; 9 | 10 | const log = debug('fcc:dataLayer:mongo:user'); 11 | 12 | function doesExist(Model, options) { 13 | return Model.find(options).exec(); 14 | } 15 | 16 | export function getUser(root, { email }) { 17 | return new Promise(async function getUserPromise(resolve, reject) { 18 | if (!isString(email) || !validator.isEmail(email)) { 19 | reject( 20 | new TypeError(`Expected a valid email, got ${JSON.stringify(email)}`) 21 | ); 22 | return null; 23 | } 24 | log(`finding user for ${email}`); 25 | const found = await UserModel.findOne({ email }).exec(); 26 | log(`found? ${!!found}`); 27 | if (isEmpty(found)) { 28 | reject(new Error(`No user found for ${email}`)); 29 | return null; 30 | } 31 | return resolve(found); 32 | }); 33 | } 34 | 35 | export async function createUser(root, vars, ctx) { 36 | const { decoded } = verifyWebToken(ctx); 37 | const { email, name, sub: id } = decoded; 38 | if (!isString(email) || !validator.isEmail(email)) { 39 | throw new Error('You must provide a valid email'); 40 | } 41 | const newUser = { name, email }; 42 | let accountLinkId = decoded[namespace + 'accountLinkId']; 43 | if (accountLinkId) { 44 | newUser.accountLinkId = accountLinkId; 45 | } else { 46 | accountLinkId = uuid(); 47 | newUser.accountLinkId = accountLinkId; 48 | updateAppMetaData(id, { accountLinkId }); 49 | } 50 | const exists = await asyncErrorHandler( 51 | doesExist(UserModel, { accountLinkId }) 52 | ); 53 | if (isEmpty(exists)) { 54 | const newAccount = new UserModel(newUser); 55 | return await asyncErrorHandler( 56 | newAccount.save(), 57 | 'Something went wrong creating your account, please try again' 58 | ); 59 | } else { 60 | throw new Error('Account already in use'); 61 | } 62 | } 63 | 64 | export async function deleteUser(root, vars, ctx) { 65 | const { decoded } = verifyWebToken(ctx); 66 | const { accountLinkId } = vars; 67 | const loggedInId = decoded[namespace + 'accountLinkId']; 68 | 69 | if (loggedInId !== accountLinkId) { 70 | throw new Error('You can delete only your account'); 71 | } 72 | const removedUser = await UserModel.findOneAndRemove({ accountLinkId }); 73 | if (!removedUser) { 74 | throw new Error( 75 | 'There is no account with this accountLinkId ' + accountLinkId 76 | ); 77 | } 78 | return removedUser; 79 | } 80 | -------------------------------------------------------------------------------- /src/graphql/errors/index.js: -------------------------------------------------------------------------------- 1 | import { createError } from 'apollo-errors'; 2 | 3 | export const AuthorizationError = createError('AuthorizationError', { 4 | message: 'You are not authorized.' 5 | }); 6 | -------------------------------------------------------------------------------- /src/graphql/resolvers/communityEvent.js: -------------------------------------------------------------------------------- 1 | import { 2 | getCommunityEvent, 3 | getCommunityEvents, 4 | createCommunityEvent, 5 | deleteCommunityEvent 6 | } from '../../dataLayer/mongo/communityEvent'; 7 | 8 | export const communityEventResolvers = { 9 | Query: { 10 | getCommunityEvent, 11 | getCommunityEvents 12 | }, 13 | Mutation: { 14 | createCommunityEvent, 15 | deleteCommunityEvent 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/graphql/resolvers/directives.js: -------------------------------------------------------------------------------- 1 | import { AuthorizationError } from '../errors'; 2 | import { verifyWebToken as _verifyWebToken } from '../../auth'; 3 | import { asyncErrorHandler } from '../../utils'; 4 | 5 | /* 6 | Interface: { 7 | Directive: ( 8 | next: Resolver 9 | source: any , Example 10 | args: any, passed to directive 11 | ctx: Lambda context 12 | ) => | 13 | } 14 | */ 15 | 16 | export const createDirectives = (verifyWebToken = _verifyWebToken) => ({ 17 | isAuthenticatedOnField: (next, source, args, ctx) => { 18 | const { isAuth } = verifyWebToken(ctx); 19 | return asyncErrorHandler(next().then(result => (isAuth ? result : null))); 20 | }, 21 | isAuthenticatedOnQuery: (next, source, args, ctx) => { 22 | const { isAuth, error } = verifyWebToken(ctx); 23 | if (isAuth) { 24 | return asyncErrorHandler(next()); 25 | } 26 | throw new AuthorizationError({ 27 | message: `You are not authorized, ${error.message}` 28 | }); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /src/graphql/resolvers/directives.test.js: -------------------------------------------------------------------------------- 1 | /* global expect beforeEach */ 2 | import sinon from 'sinon'; 3 | import sinonStubPromise from 'sinon-stub-promise'; 4 | import { createDirectives } from './directives'; 5 | 6 | sinonStubPromise(sinon); 7 | const testErrorMsg = 'Test error message'; 8 | let nextSpy = sinon.spy(); 9 | let nextPromiseStub = sinon.stub().returnsPromise(); 10 | let authTrueStub = sinon.stub().returns({ isAuth: true }); 11 | let authFalseStub = sinon 12 | .stub() 13 | .returns({ isAuth: false, error: { message: testErrorMsg } }); 14 | 15 | beforeEach(() => { 16 | authFalseStub.resetHistory(); 17 | authTrueStub.resetHistory(); 18 | nextSpy.resetHistory(); 19 | nextPromiseStub.resetHistory(); 20 | }); 21 | 22 | describe('isAuthenticatedOnField', () => { 23 | it('should return null if authenication fails', () => { 24 | const { isAuthenticatedOnField } = createDirectives(authFalseStub); 25 | const secretValue = 'secret squirrel'; 26 | nextPromiseStub.resolves(secretValue); 27 | const result = isAuthenticatedOnField(nextPromiseStub); 28 | 29 | expect(authFalseStub.calledOnce).toBe(true); 30 | expect(result.resolved).toBe(true); 31 | expect(result.resolveValue).toBe(null); 32 | }); 33 | 34 | it('should return the secretValue if authentication succeeds', () => { 35 | const { isAuthenticatedOnField } = createDirectives(authTrueStub); 36 | const secretValue = 'secret squirrel'; 37 | nextPromiseStub.resolves(secretValue); 38 | const result = isAuthenticatedOnField(nextPromiseStub); 39 | 40 | expect(authTrueStub.calledOnce).toBe(true); 41 | expect(result.resolved).toBe(true); 42 | expect(result.resolveValue).toBe(secretValue); 43 | }); 44 | }); 45 | 46 | describe('isAuthenticatedOnQuery', () => { 47 | it('should throw an error is auth fails', () => { 48 | const { isAuthenticatedOnQuery } = createDirectives(authFalseStub); 49 | 50 | expect(() => { 51 | isAuthenticatedOnQuery(nextSpy); 52 | }).toThrowError(testErrorMsg); 53 | expect(nextSpy.called).toBe(false); 54 | }); 55 | 56 | it('should call next if auth succeeds', () => { 57 | const { isAuthenticatedOnQuery } = createDirectives(authTrueStub); 58 | isAuthenticatedOnQuery(nextPromiseStub); 59 | 60 | expect(nextPromiseStub.calledOnce).toBe(true); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/graphql/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import { mergeResolvers } from 'merge-graphql-schemas'; 2 | 3 | import { userResolvers } from './user'; 4 | import { communityEventResolvers } from './communityEvent'; 5 | 6 | export { createDirectives } from './directives'; 7 | 8 | export default mergeResolvers([userResolvers, communityEventResolvers]); 9 | -------------------------------------------------------------------------------- /src/graphql/resolvers/user.js: -------------------------------------------------------------------------------- 1 | import { createUser, getUser, deleteUser } from '../../dataLayer/mongo/user'; 2 | 3 | export const userResolvers = { 4 | Query: { 5 | getUser 6 | }, 7 | Mutation: { 8 | createUser, 9 | deleteUser 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/CommunityEvent/index.js: -------------------------------------------------------------------------------- 1 | import CommunityEventType from './type'; 2 | import Query from './query'; 3 | import Mutation from './mutation'; 4 | 5 | export default ` 6 | ${CommunityEventType} 7 | ${Query} 8 | ${Mutation} 9 | `; 10 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/CommunityEvent/input.js: -------------------------------------------------------------------------------- 1 | // Users by their external id or email address 2 | export default ` 3 | input UserInput { 4 | externalId: String 5 | email: String 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/CommunityEvent/mutation.js: -------------------------------------------------------------------------------- 1 | // TODO: date should be a custom type 2 | export default ` 3 | type Mutation { 4 | createCommunityEvent(title: String! 5 | title: String! 6 | description: String! 7 | attendees: [UserInput] 8 | date: String! 9 | imageUrl: String 10 | isLocked: Boolean): CommunityEvent @isAuthenticatedOnQuery 11 | deleteCommunityEvent(externalId: String!): CommunityEvent @isAuthenticatedOnQuery 12 | } 13 | `; 14 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/CommunityEvent/query.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Query { 3 | getCommunityEvent(externalId: String 4 | title: String): CommunityEvent, 5 | getCommunityEvents(externalId: String 6 | title: String): [CommunityEvent] 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/CommunityEvent/type.js: -------------------------------------------------------------------------------- 1 | import InputTypes from './input'; 2 | 3 | export default ` 4 | type CommunityEvent { 5 | externalId: String 6 | title: String! 7 | description: String! 8 | owner: User! 9 | attendees: [User] 10 | date: String! 11 | imageUrl: String 12 | } 13 | 14 | ${InputTypes} 15 | 16 | `; 17 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/User/index.js: -------------------------------------------------------------------------------- 1 | import UserType from './type'; 2 | import Query from './query'; 3 | import Mutation from './mutation'; 4 | 5 | export default ` 6 | ${UserType} 7 | ${Query} 8 | ${Mutation} 9 | `; 10 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/User/input.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | input CompletedChallengeInput { 3 | completedDate: Int!, 4 | externalId: String!, 5 | solution: String, 6 | githubLink: String 7 | } 8 | `; 9 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/User/mutation.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Mutation { 3 | createUser: User @isAuthenticatedOnQuery 4 | deleteUser(accountLinkId: String!): User @isAuthenticatedOnQuery 5 | } 6 | `; 7 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/User/query.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | type Query { 3 | getUser(email: String 4 | externalId: String 5 | ): User @isAuthenticatedOnQuery 6 | } 7 | `; 8 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/User/type.js: -------------------------------------------------------------------------------- 1 | import InputTypes from './input'; 2 | 3 | export default ` 4 | 5 | type CompletedChallenge { 6 | completedDate: Int!, 7 | id: String!, 8 | solution: String, 9 | githubLink: String 10 | } 11 | 12 | type User { 13 | externalId: String 14 | accountLinkId: String! 15 | email: String! 16 | isCheater: Boolean 17 | username: String 18 | name: String 19 | sendQuincyEmail: Boolean 20 | isLocked: Boolean 21 | currentChallengeId: String 22 | isHonest: Boolean 23 | isFrontEndCert: Boolean 24 | isDataVisCert: Boolean 25 | isBackEndCert: Boolean 26 | isFullStackCert: Boolean 27 | isRespWebDesignCert: Boolean 28 | is2018DataVisCert: Boolean 29 | isFrontEndLibsCert: Boolean 30 | isJsAlgoDataStructCert: Boolean 31 | isApisMicroservicesCert: Boolean 32 | isInfosecQaCert: Boolean 33 | isChallengeMapMigrated: Boolean 34 | completedChallenges: [CompletedChallenge]! 35 | theme: String 36 | } 37 | 38 | ${InputTypes} 39 | 40 | `; 41 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/directives/index.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | directive @isAuthenticatedOnField on FIELD | FIELD_DEFINITION 3 | directive @isAuthenticatedOnQuery on FIELD | FIELD_DEFINITION 4 | `; 5 | -------------------------------------------------------------------------------- /src/graphql/typeDefs/index.js: -------------------------------------------------------------------------------- 1 | import { mergeTypes } from 'merge-graphql-schemas'; 2 | import User from './User'; 3 | import CommunityEvent from './CommunityEvent'; 4 | import directives from './directives'; 5 | 6 | export default mergeTypes([User, CommunityEvent, directives]); 7 | -------------------------------------------------------------------------------- /src/handler.js: -------------------------------------------------------------------------------- 1 | import { graphqlLambda } from 'apollo-server-lambda'; 2 | import lambdaPlayground from 'graphql-playground-middleware-lambda'; 3 | import { makeExecutableSchema } from 'graphql-tools'; 4 | import debug from 'debug'; 5 | 6 | import typeDefs from './graphql/typeDefs'; 7 | import { default as resolvers, createDirectives } from './graphql/resolvers'; 8 | import connectToDatabase from './dataLayer'; 9 | 10 | const log = debug('fcc:handler'); 11 | 12 | export const graphqlSchema = makeExecutableSchema({ 13 | typeDefs, 14 | resolvers, 15 | directiveResolvers: createDirectives(), 16 | logger: console 17 | }); 18 | 19 | exports.graphqlHandler = async function graphqlHandler( 20 | event, 21 | context, 22 | callback 23 | ) { 24 | /* eslint-disable max-len */ 25 | /* Cause Lambda to freeze the process and save state data after 26 | the callback is called. the effect is that new handler invocations 27 | will be able to re-use the database connection. 28 | See https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html 29 | and https://www.mongodb.com/blog/post/optimizing-aws-lambda-performance-with-mongodb-atlas-and-nodejs */ 30 | /* eslint-enable max-len */ 31 | context.callbackWaitsForEmptyEventLoop = false; 32 | 33 | function callbackFilter(error, output) { 34 | if (!output.headers) { 35 | output.headers = {}; 36 | } 37 | // eslint-disable-next-line no-param-reassign 38 | output.headers['Access-Control-Allow-Origin'] = '*'; 39 | output.headers['Access-Control-Allow-Credentials'] = true; 40 | output.headers['Content-Type'] = 'application/json'; 41 | 42 | callback(error, output); 43 | } 44 | 45 | const handler = graphqlLambda((event, context) => { 46 | const { headers } = event; 47 | const { functionName } = context; 48 | 49 | return { 50 | schema: graphqlSchema, 51 | context: { 52 | headers, 53 | functionName, 54 | event, 55 | context 56 | } 57 | }; 58 | }); 59 | 60 | try { 61 | await connectToDatabase(); 62 | } catch (err) { 63 | log('MongoDB connection error: ', err); 64 | // TODO: return 500? 65 | /* eslint-disable no-process-exit */ 66 | process.exit(); 67 | /* eslint-enable no-process-exit */ 68 | } 69 | 70 | try { 71 | JSON.parse(event.body); 72 | } catch (err) { 73 | const msg = 'Invalid JSON'; 74 | log(msg, err); 75 | return callback(null, { 76 | body: msg, 77 | statusCode: 422 78 | }); 79 | } 80 | 81 | return handler(event, context, callbackFilter); 82 | }; 83 | 84 | exports.apiHandler = lambdaPlayground({ 85 | endpoint: process.env.GRAPHQL_ENDPOINT_URL 86 | ? process.env.GRAPHQL_ENDPOINT_URL 87 | : '/production/graphql' 88 | }); 89 | -------------------------------------------------------------------------------- /src/utils/asyncErrorHandler.js: -------------------------------------------------------------------------------- 1 | export default function asyncErrorHandler(promise) { 2 | return promise.catch(err => { 3 | throw err; 4 | }); 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export { default as asyncErrorHandler } from './asyncErrorHandler'; 2 | -------------------------------------------------------------------------------- /test/integration/__snapshots__/eventFlow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createEvent should create an event by query: new event 1`] = ` 4 | Object { 5 | "description": "The start of POSIX time", 6 | "owner": Object { 7 | "email": "briankernighan@example.com", 8 | "externalId": null, 9 | }, 10 | "title": "epoch", 11 | } 12 | `; 13 | 14 | exports[`createEvent should create an event by query: no errors 1`] = `undefined`; 15 | 16 | exports[`createEvent should raise an error without email in token: no email 1`] = ` 17 | Array [ 18 | [GraphQLError: You must provide a valid email], 19 | ] 20 | `; 21 | 22 | exports[`createEvent should raise an error without email in token: null 1`] = `null`; 23 | 24 | exports[`createEvent should return null and an auth error with an invalid token: no auth error 1`] = ` 25 | Array [ 26 | [GraphQLError: You are not authorized, jwt malformed], 27 | ] 28 | `; 29 | 30 | exports[`createEvent should return null and an auth error without a token: not logged in 1`] = ` 31 | Array [ 32 | [GraphQLError: You must supply a JSON Web Token for authorization, are you logged in?], 33 | ] 34 | `; 35 | 36 | exports[`getEvent should return an event after one has been created: event found 1`] = ` 37 | Object { 38 | "description": "The start of POSIX time", 39 | "owner": Object { 40 | "email": "briankernighan@example.com", 41 | }, 42 | "title": "epoch", 43 | } 44 | `; 45 | 46 | exports[`getEvent should return an event after one has been created: no errors 1`] = `undefined`; 47 | 48 | exports[`getEvent should return errors for a malformed query: malformed query error 1`] = ` 49 | Array [ 50 | [GraphQLError: Expected a valid externalId or title], 51 | ] 52 | `; 53 | 54 | exports[`getEvent should return errors for a query skipping a mandatory field: skipped mandatory field error 1`] = ` 55 | Array [ 56 | [GraphQLError: Expected an event ID or title], 57 | ] 58 | `; 59 | 60 | exports[`getEvent should return null if no event has been found: no event error 1`] = `undefined`; 61 | 62 | exports[`getEvent should return null if no event has been found: null event 1`] = `null`; 63 | -------------------------------------------------------------------------------- /test/integration/__snapshots__/userFlow.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`createUser should create a user by query: new user 1`] = ` 4 | Object { 5 | "email": "charlie@thebear.me", 6 | "name": "Charlie", 7 | } 8 | `; 9 | 10 | exports[`createUser should create a user by query: no errors 1`] = `undefined`; 11 | 12 | exports[`createUser should not create duplicate accounts: duplicate user error 1`] = ` 13 | Array [ 14 | [GraphQLError: Account already in use], 15 | ] 16 | `; 17 | 18 | exports[`createUser should not create duplicate accounts: no duplicate users 1`] = ` 19 | Array [ 20 | Object { 21 | "accountLinkId": "76b27a04-f537-4f7d-89a9-b469bf81208b", 22 | "email": "charlie@thebear.me", 23 | "name": "Charlie", 24 | }, 25 | ] 26 | `; 27 | 28 | exports[`createUser should raise an error without email in token: no email 1`] = ` 29 | Array [ 30 | [GraphQLError: You must provide a valid email], 31 | ] 32 | `; 33 | 34 | exports[`createUser should raise an error without email in token: null 1`] = `null`; 35 | 36 | exports[`createUser should return null and an auth error with an invalid token: no auth error 1`] = ` 37 | Array [ 38 | [GraphQLError: You are not authorized, jwt malformed], 39 | ] 40 | `; 41 | 42 | exports[`createUser should return null and an auth error with an invalid token: no user 1`] = `null`; 43 | 44 | exports[`createUser should return null and an auth error without a token: no user 1`] = `null`; 45 | 46 | exports[`createUser should return null and an auth error without a token: not logged in 1`] = ` 47 | Array [ 48 | [GraphQLError: You must supply a JSON Web Token for authorization, are you logged in?], 49 | ] 50 | `; 51 | 52 | exports[`getUser should return a user after one has been created: no errors 1`] = `undefined`; 53 | 54 | exports[`getUser should return a user after one has been created: user found 1`] = ` 55 | Object { 56 | "getUser": Object { 57 | "email": "charlie@thebear.me", 58 | "name": "Charlie", 59 | }, 60 | } 61 | `; 62 | 63 | exports[`getUser should return errors for a malformed query: malformed query error 1`] = ` 64 | Array [ 65 | [GraphQLError: Expected a valid email, got "Ooops!"], 66 | ] 67 | `; 68 | 69 | exports[`getUser should return errors for a query skipping a mandatory field: skipped mandatory field error 1`] = ` 70 | Array [ 71 | [GraphQLError: Expected a valid email, got undefined], 72 | ] 73 | `; 74 | 75 | exports[`getUser should return null and an auth error with an invalid token: no auth error 1`] = ` 76 | Array [ 77 | [GraphQLError: You are not authorized, jwt malformed], 78 | ] 79 | `; 80 | 81 | exports[`getUser should return null and an auth error with an invalid token: no user 1`] = `null`; 82 | 83 | exports[`getUser should return null and an auth error without a token: no user 1`] = `null`; 84 | 85 | exports[`getUser should return null and an auth error without a token: not logged in 1`] = ` 86 | Array [ 87 | [GraphQLError: You must supply a JSON Web Token for authorization, are you logged in?], 88 | ] 89 | `; 90 | 91 | exports[`getUser should return null if no user has been found: no user error 1`] = ` 92 | Array [ 93 | [GraphQLError: No user found for nowhere@tobe.seen], 94 | ] 95 | `; 96 | 97 | exports[`getUser should return null if no user has been found: null user 1`] = `null`; 98 | -------------------------------------------------------------------------------- /test/integration/eventFlow.test.js: -------------------------------------------------------------------------------- 1 | /* global beforeAll afterAll expect */ 2 | import mongoose from 'mongoose'; 3 | import { graphql } from 'graphql'; 4 | import { createUser } from '../../src/dataLayer/mongo/user'; 5 | import { createCommunityEvent } from '../../src/dataLayer/mongo/communityEvent'; 6 | import { graphqlSchema } from '../../src/handler'; 7 | 8 | const contextNoToken = global.mockedContextWithOutToken; 9 | const invalidContext = global.mockedContextWithInValidToken; 10 | const validContextCharlie = global.mockedContextWithValidTokenForCharlie; 11 | const contextNoEmail = global.mockedContextWithNoEmailToken; 12 | const validContextForBrian = global.mockedContextWithValidTokenForBrian; 13 | const validContextForDennis = global.mockedContextWithValidTokenForDennis; 14 | 15 | const event = { 16 | title: 'epoch', 17 | description: 'The start of POSIX time', 18 | date: 'Thu 1 Jan 1970 00:00:00' 19 | }; 20 | 21 | beforeAll(async function beforeAllTests() { 22 | await mongoose.connect(global.__MONGO_URI__); 23 | 24 | // Create two test users 25 | await createUser({}, {}, validContextForBrian); 26 | await createUser({}, {}, validContextForDennis); 27 | 28 | // Create a test event 29 | await createCommunityEvent({}, event, validContextForBrian); 30 | }); 31 | 32 | afterAll(async function afterAllTests() { 33 | await mongoose.connection.db.dropDatabase(); 34 | await mongoose.disconnect(); 35 | }); 36 | 37 | // language=GraphQL 38 | 39 | const createCommunityEventQuery = ` 40 | mutation createCommunityEvent { 41 | createCommunityEvent( 42 | title: "epoch" 43 | description: "The start of POSIX time" 44 | date: "Thu 1 Jan 1970 00:00:0" 45 | attendees: [{email: "dennisritchie@example.com"}] 46 | ) { 47 | title 48 | description 49 | owner { 50 | email 51 | externalId 52 | } 53 | } 54 | } 55 | `; 56 | 57 | const expectedCommunityEventQuery = ` 58 | query { 59 | getCommunityEvent(title: "epoch") { 60 | title 61 | description 62 | owner { 63 | email 64 | } 65 | } 66 | } 67 | `; 68 | 69 | const expectedNoCommunityEventQuery = ` 70 | query { 71 | getCommunityEvent(title: "yeah nah") { 72 | title 73 | description 74 | owner { 75 | email 76 | } 77 | } 78 | } 79 | `; 80 | 81 | const skippedMandatoryFieldQuery = ` 82 | query { 83 | getCommunityEvent { 84 | title 85 | description 86 | owner { 87 | email 88 | } 89 | } 90 | } 91 | `; 92 | 93 | const malformedQuery = ` 94 | query { 95 | getCommunityEvent(externalId: "yeah nah") { 96 | title 97 | description 98 | owner { 99 | email 100 | } 101 | } 102 | } 103 | `; 104 | 105 | const rootValue = {}; 106 | 107 | describe('createEvent', () => { 108 | it('should return null and an auth error without a token', done => { 109 | expect.assertions(1); 110 | 111 | graphql(graphqlSchema, createCommunityEventQuery, rootValue, contextNoToken) 112 | .then(({ errors }) => { 113 | expect(errors).toMatchSnapshot('not logged in'); 114 | return; 115 | }) 116 | .then(done) 117 | .catch(done); 118 | }); 119 | 120 | it('should return null and an auth error with an invalid token', done => { 121 | expect.assertions(1); 122 | 123 | graphql(graphqlSchema, createCommunityEventQuery, rootValue, invalidContext) 124 | .then(({ errors }) => { 125 | expect(errors).toMatchSnapshot('no auth error'); 126 | return; 127 | }) 128 | .then(done) 129 | .catch(done); 130 | }); 131 | 132 | it('should create an event by query', done => { 133 | expect.assertions(2); 134 | 135 | graphql( 136 | graphqlSchema, 137 | createCommunityEventQuery, 138 | rootValue, 139 | validContextForBrian 140 | ) 141 | .then(({ data, errors }) => { 142 | expect(data.createCommunityEvent).toMatchSnapshot('new event'); 143 | expect(errors).toMatchSnapshot('no errors'); 144 | return; 145 | }) 146 | .then(done) 147 | .catch(done); 148 | }); 149 | 150 | it('should raise an error without email in token', done => { 151 | expect.assertions(2); 152 | 153 | graphql(graphqlSchema, createCommunityEventQuery, rootValue, contextNoEmail) 154 | .then(({ data, errors }) => { 155 | expect(data.createCommunityEvent).toMatchSnapshot('null'); 156 | expect(errors).toMatchSnapshot('no email'); 157 | return; 158 | }) 159 | .then(done) 160 | .catch(done); 161 | }); 162 | }); 163 | 164 | describe('getEvent', () => { 165 | it('should return an event after one has been created', done => { 166 | expect.assertions(2); 167 | 168 | graphql( 169 | graphqlSchema, 170 | expectedCommunityEventQuery, 171 | rootValue, 172 | validContextCharlie 173 | ) 174 | .then(({ data, errors }) => { 175 | expect(data.getCommunityEvent).toMatchSnapshot('event found'); 176 | expect(errors).toMatchSnapshot('no errors'); 177 | return; 178 | }) 179 | .then(done) 180 | .catch(done); 181 | }); 182 | 183 | it('should return null if no event has been found', done => { 184 | expect.assertions(2); 185 | 186 | graphql( 187 | graphqlSchema, 188 | expectedNoCommunityEventQuery, 189 | rootValue, 190 | validContextCharlie 191 | ) 192 | .then(result => { 193 | const { data, errors } = result; 194 | expect(data.getCommunityEvent).toMatchSnapshot('null event'); 195 | expect(errors).toMatchSnapshot('no event error'); 196 | return; 197 | }) 198 | .then(done) 199 | .catch(done); 200 | }); 201 | 202 | it('should return errors for a query skipping a mandatory field', done => { 203 | expect.assertions(1); 204 | 205 | graphql( 206 | graphqlSchema, 207 | skippedMandatoryFieldQuery, 208 | rootValue, 209 | validContextCharlie 210 | ) 211 | .then(result => { 212 | const { errors } = result; 213 | expect(errors).toMatchSnapshot('skipped mandatory field error'); 214 | return; 215 | }) 216 | .then(done) 217 | .catch(done); 218 | }); 219 | 220 | it('should return errors for a malformed query', done => { 221 | expect.assertions(2); 222 | 223 | graphql(graphqlSchema, malformedQuery, rootValue, validContextCharlie) 224 | .then(result => { 225 | const { data, errors } = result; 226 | expect(data.getCommunityEvent).toBe(null); 227 | expect(errors).toMatchSnapshot('malformed query error'); 228 | return; 229 | }) 230 | .then(done) 231 | .catch(done); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /test/integration/userFlow.test.js: -------------------------------------------------------------------------------- 1 | /* global beforeAll afterAll expect */ 2 | import mongoose from 'mongoose'; 3 | import { graphql } from 'graphql'; 4 | import { pick } from 'lodash'; 5 | import UserModel from '../../src/dataLayer/model/user'; 6 | import { graphqlSchema } from '../../src/handler'; 7 | 8 | beforeAll(async function beforeAllTests() { 9 | await mongoose.connect(global.__MONGO_URI__); 10 | }); 11 | 12 | afterAll(async function afterAllTests() { 13 | await mongoose.disconnect(); 14 | }); 15 | 16 | const contextNoToken = global.mockedContextWithOutToken; 17 | const invalidContext = global.mockedContextWithInValidToken; 18 | const validContextCharlie = global.mockedContextWithValidTokenForCharlie; 19 | const validContextForLola = global.mockedContextWithValidTokenForLola; 20 | const contextNoEmail = global.mockedContextWithNoEmailToken; 21 | // language=GraphQL 22 | 23 | const createUserQuery = ` 24 | mutation createUser { 25 | createUser { 26 | email 27 | name 28 | } 29 | } 30 | `; 31 | 32 | const expectedUserQuery = ` 33 | query { 34 | getUser(email: "charlie@thebear.me") { 35 | name 36 | email 37 | } 38 | } 39 | `; 40 | 41 | const expectedNoUserQuery = ` 42 | query { 43 | getUser(email: "nowhere@tobe.seen") { 44 | name 45 | email 46 | } 47 | } 48 | `; 49 | 50 | const skippedMandatoryFieldQuery = ` 51 | query { 52 | getUser { 53 | name 54 | email 55 | } 56 | } 57 | `; 58 | 59 | const malformedQuery = ` 60 | query { 61 | getUser(email: "Ooops!") { 62 | name 63 | email 64 | } 65 | } 66 | `; 67 | 68 | const rootValue = {}; 69 | 70 | describe('createUser', () => { 71 | it('should return null and an auth error without a token', done => { 72 | expect.assertions(2); 73 | 74 | graphql(graphqlSchema, createUserQuery, rootValue, contextNoToken) 75 | .then(({ data, errors }) => { 76 | expect(data.createUser).toMatchSnapshot('no user'); 77 | expect(errors).toMatchSnapshot('not logged in'); 78 | return; 79 | }) 80 | .then(done) 81 | .catch(done); 82 | }); 83 | 84 | it('should return null and an auth error with an invalid token', done => { 85 | expect.assertions(2); 86 | 87 | graphql(graphqlSchema, createUserQuery, rootValue, invalidContext) 88 | .then(({ data, errors }) => { 89 | expect(data.createUser).toMatchSnapshot('no user'); 90 | expect(errors).toMatchSnapshot('no auth error'); 91 | return; 92 | }) 93 | .then(done) 94 | .catch(done); 95 | }); 96 | 97 | it('should create a user by query', done => { 98 | expect.assertions(2); 99 | 100 | graphql(graphqlSchema, createUserQuery, rootValue, validContextCharlie) 101 | .then(({ data, errors }) => { 102 | expect(data.createUser).toMatchSnapshot('new user'); 103 | expect(errors).toMatchSnapshot('no errors'); 104 | return; 105 | }) 106 | .then(done) 107 | .catch(done); 108 | }); 109 | 110 | it('should raise an error without email in token', done => { 111 | graphql(graphqlSchema, createUserQuery, rootValue, contextNoEmail) 112 | .then(({ data, errors }) => { 113 | expect(data.createUser).toMatchSnapshot('null'); 114 | expect(errors).toMatchSnapshot('no email'); 115 | return; 116 | }) 117 | .then(done) 118 | .catch(done); 119 | }); 120 | 121 | it('should not create duplicate accounts', async done => { 122 | expect.assertions(4); 123 | const usersFound = await UserModel.find({ 124 | email: 'charlie@thebear.me' 125 | }).exec(); 126 | expect(usersFound).toHaveLength(1); 127 | expect( 128 | usersFound.map(obj => pick(obj, ['name', 'email', 'accountLinkId'])) 129 | ).toMatchSnapshot('no duplicate users'); 130 | 131 | graphql(graphqlSchema, createUserQuery, rootValue, validContextCharlie) 132 | .then(async function({ data, errors }) { 133 | expect(data.createUser).toBeNull(); 134 | expect(errors).toMatchSnapshot('duplicate user error'); 135 | return; 136 | }) 137 | .then(done) 138 | .catch(done); 139 | }); 140 | }); 141 | 142 | describe('getUser', () => { 143 | it('should return null and an auth error without a token', done => { 144 | expect.assertions(2); 145 | 146 | graphql(graphqlSchema, expectedUserQuery, rootValue, contextNoToken) 147 | .then(({ data, errors }) => { 148 | expect(data.getUser).toMatchSnapshot('no user'); 149 | expect(errors).toMatchSnapshot('not logged in'); 150 | return; 151 | }) 152 | .then(done) 153 | .catch(done); 154 | }); 155 | 156 | it('should return null and an auth error with an invalid token', done => { 157 | expect.assertions(2); 158 | 159 | graphql(graphqlSchema, expectedUserQuery, rootValue, invalidContext) 160 | .then(({ data, errors }) => { 161 | expect(data.getUser).toMatchSnapshot('no user'); 162 | expect(errors).toMatchSnapshot('no auth error'); 163 | return; 164 | }) 165 | .then(done) 166 | .catch(done); 167 | }); 168 | 169 | it('should return a user after one has been created', done => { 170 | expect.assertions(2); 171 | 172 | graphql(graphqlSchema, expectedUserQuery, rootValue, validContextCharlie) 173 | .then(({ data, errors }) => { 174 | expect(data).toMatchSnapshot('user found'); 175 | expect(errors).toMatchSnapshot('no errors'); 176 | return; 177 | }) 178 | .then(done) 179 | .catch(done); 180 | }); 181 | 182 | it('should return null if no user has been found', done => { 183 | expect.assertions(2); 184 | 185 | graphql(graphqlSchema, expectedNoUserQuery, rootValue, validContextCharlie) 186 | .then(result => { 187 | const { data, errors } = result; 188 | expect(data.getUser).toMatchSnapshot('null user'); 189 | expect(errors).toMatchSnapshot('no user error'); 190 | return; 191 | }) 192 | .then(done) 193 | .catch(done); 194 | }); 195 | 196 | it('should return errors for a query skipping a mandatory field', done => { 197 | expect.assertions(1); 198 | 199 | graphql( 200 | graphqlSchema, 201 | skippedMandatoryFieldQuery, 202 | rootValue, 203 | validContextCharlie 204 | ) 205 | .then(result => { 206 | const { errors } = result; 207 | expect(errors).toMatchSnapshot('skipped mandatory field error'); 208 | return; 209 | }) 210 | .then(done) 211 | .catch(done); 212 | }); 213 | 214 | it('should return errors for a malformed query', done => { 215 | expect.assertions(2); 216 | 217 | graphql(graphqlSchema, malformedQuery, rootValue, validContextCharlie) 218 | .then(result => { 219 | const { data, errors } = result; 220 | expect(data.getUser).toBe(null); 221 | expect(errors).toMatchSnapshot('malformed query error'); 222 | return; 223 | }) 224 | .then(done) 225 | .catch(done); 226 | }); 227 | }); 228 | -------------------------------------------------------------------------------- /test/utils/setup.js: -------------------------------------------------------------------------------- 1 | const MongodbMemoryServer = require('mongodb-memory-server'); 2 | 3 | const MONGO_DB_NAME = 'jest'; 4 | const mongod = new MongodbMemoryServer.default({ 5 | instance: { 6 | dbName: MONGO_DB_NAME 7 | }, 8 | binary: { 9 | version: '3.2.19' 10 | } 11 | }); 12 | 13 | module.exports = function() { 14 | global.__MONGOD__ = mongod; 15 | global.__MONGO_DB_NAME__ = MONGO_DB_NAME; 16 | }; 17 | -------------------------------------------------------------------------------- /test/utils/teardown.js: -------------------------------------------------------------------------------- 1 | module.exports = function() { 2 | return global.__MONGOD__.stop(); 3 | }; 4 | -------------------------------------------------------------------------------- /test/utils/test-environment.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const NodeEnvironment = require('jest-environment-node'); 3 | 4 | // can be found in ~/src/auth/index.js 5 | // not 'required' due to jest no knowing how to read es6 modules 6 | const namespace = 'https://auth-ns.freecodecamp.org/'; 7 | 8 | const jwtEncoded = process.env.JWT_CERT; 9 | const JWT_CERT = Buffer.from(jwtEncoded, 'base64').toString('utf8'); 10 | 11 | class MongoEnvironment extends NodeEnvironment { 12 | constructor(config) { 13 | super(config); 14 | } 15 | 16 | async setup() { 17 | this.global.__MONGO_URI__ = await global.__MONGOD__.getConnectionString(); 18 | this.global.__MONGO_DB_NAME__ = global.__MONGO_DB_NAME__; 19 | 20 | this.global.idOfCharlie = '76b27a04-f537-4f7d-89a9-b469bf81208b'; 21 | this.global.idOfLola = '85a937d5-c82c-4aa9-8e0b-9f2b9a7cc36c'; 22 | this.global.idOfJane = '85a937d5-c82c-4aa9-89a9-b469bf81208b'; 23 | this.global.idOfBrian = 'd57c402c-647a-11e8-a678-14109fd1f8cb'; 24 | this.global.idOfDennis = 'e7189f2e-647a-11e8-9ed9-14109fd1f8cb'; 25 | this.global.idOfKen = 'e4a850da-647b-11e8-9fb2-14109fd1f8cb'; 26 | 27 | const token = jwt.sign( 28 | { 29 | name: 'Charlie', 30 | email: 'charlie@thebear.me', 31 | [namespace + 'accountLinkId']: this.global.idOfCharlie 32 | }, 33 | JWT_CERT 34 | ); 35 | const token2 = jwt.sign( 36 | { 37 | name: 'Lola', 38 | email: 'lola@cbbc.tv', 39 | [namespace + 'accountLinkId']: this.global.idOfLola 40 | }, 41 | JWT_CERT 42 | ); 43 | const token3 = jwt.sign( 44 | { 45 | name: 'Jane', 46 | email: 'janedoe@someplace.com', 47 | [namespace + 'accountLinkId']: this.global.idOfJane 48 | }, 49 | JWT_CERT 50 | ); 51 | const tokenWithoutEmail = jwt.sign( 52 | { 53 | name: 'Marv', 54 | [namespace + 'accountLinkId']: 'f0a102f6-4d2a-481b-9256-438c5756ffb5' 55 | }, 56 | JWT_CERT 57 | ); 58 | const tokenForBrian = jwt.sign( 59 | { 60 | name: 'Brian Kernighan', 61 | email: 'briankernighan@example.com', 62 | [namespace + 'accountLinkId']: this.global.idOfBrian 63 | }, 64 | JWT_CERT 65 | ); 66 | 67 | const tokenForDennis = jwt.sign( 68 | { 69 | name: 'Dennis Ritchie', 70 | email: 'dennisritchie@example.com', 71 | [namespace + 'accountLinkId']: this.global.idOfDennis 72 | }, 73 | JWT_CERT 74 | ); 75 | 76 | const tokenForKen = jwt.sign( 77 | { 78 | name: 'Ken Thompson', 79 | email: 'kenthompson@example.com', 80 | [namespace + 'accountLinkId']: this.global.idOfKen 81 | }, 82 | JWT_CERT 83 | ); 84 | 85 | const headers = { 86 | 'Content-Type': 'application/json' 87 | }; 88 | 89 | this.global.mockedContextWithOutToken = { headers: headers }; 90 | 91 | const headersWithValidTokenForCharlie = { 92 | ...headers, 93 | Authorization: 'Bearer ' + token 94 | }; 95 | this.global.mockedContextWithValidTokenForCharlie = { 96 | headers: headersWithValidTokenForCharlie 97 | }; 98 | 99 | const headersWithValidTokenForLola = { 100 | ...headers, 101 | authorization: 'Bearer ' + token2 102 | }; 103 | this.global.mockedContextWithValidTokenForLola = { 104 | headers: headersWithValidTokenForLola 105 | }; 106 | 107 | const headersWithValidTokenForJane = { 108 | ...headers, 109 | authorization: 'Bearer ' + token3 110 | }; 111 | this.global.mockedContextWithValidTokenForJane = { 112 | headers: headersWithValidTokenForJane 113 | }; 114 | 115 | const headersWithNoEmailToken = { 116 | ...headers, 117 | authorization: 'Bearer ' + tokenWithoutEmail 118 | }; 119 | this.global.mockedContextWithNoEmailToken = { 120 | headers: headersWithNoEmailToken 121 | }; 122 | 123 | const headersWithInValidToken = { 124 | ...headers, 125 | Authorization: 'Bearer 123' 126 | }; 127 | this.global.mockedContextWithInValidToken = { 128 | headers: headersWithInValidToken 129 | }; 130 | 131 | const headersWithValidTokenForBrian = { 132 | ...headers, 133 | authorization: 'Bearer ' + tokenForBrian 134 | }; 135 | this.global.mockedContextWithValidTokenForBrian = { 136 | headers: headersWithValidTokenForBrian 137 | }; 138 | 139 | const headersWithValidTokenForDennis = { 140 | ...headers, 141 | authorization: 'Bearer ' + tokenForDennis 142 | }; 143 | this.global.mockedContextWithValidTokenForDennis = { 144 | headers: headersWithValidTokenForDennis 145 | }; 146 | 147 | const headersWithValidTokenForKen = { 148 | ...headers, 149 | authorization: 'Bearer ' + tokenForKen 150 | }; 151 | this.global.mockedContextWithValidTokenForKen = { 152 | headers: headersWithValidTokenForKen 153 | }; 154 | 155 | await super.setup(); 156 | } 157 | 158 | async teardown() { 159 | await super.teardown(); 160 | } 161 | 162 | runScript(script) { 163 | return super.runScript(script); 164 | } 165 | } 166 | 167 | module.exports = MongoEnvironment; 168 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const slsw = require('serverless-webpack'); 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | const include = './src/_webpack/include.js'; 6 | const entries = {}; 7 | 8 | Object.keys(slsw.lib.entries).forEach( 9 | key => (entries[key] = [include, slsw.lib.entries[key]]) 10 | ); 11 | 12 | module.exports = { 13 | entry: entries, 14 | target: 'node', 15 | // Generate sourcemaps for proper error messages 16 | devtool: 'source-map', 17 | // Since 'aws-sdk' is not compatible with webpack, 18 | // we exclude all node dependencies 19 | externals: [nodeExternals()], 20 | mode: slsw.lib.webpack.isLocal ? 'development' : 'production', 21 | optimization: { 22 | // We no not want to minimize our code. 23 | minimize: false 24 | }, 25 | performance: { 26 | // Turn off size warnings for entry points 27 | hints: false 28 | }, 29 | // Run babel on all .js files and skip those in node_modules 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.js$/, 34 | loader: 'babel-loader', 35 | include: __dirname, 36 | exclude: /node_modules/ 37 | } 38 | ] 39 | }, 40 | output: { 41 | path: path.join(__dirname, '.webpack'), 42 | filename: '[name].js', 43 | sourceMapFilename: '[file].map' 44 | } 45 | }; 46 | --------------------------------------------------------------------------------