diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6ea7933..2d65eb0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,13 +20,13 @@ jobs: node-version: '16' - name: Build docker images - run: docker-compose build + run: docker compose build - name: Install dependencies uses: bahmutov/npm-install@v1 - name: Run tests - run: docker-compose run -e NODE_ENV=test members_api + run: NODE_ENV=test docker compose run --rm members_api - name: Cleanup diff --git a/Dockerfile b/Dockerfile index 5987453..2c11ffb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,4 +24,4 @@ COPY docker-run.sh /home/app/ RUN npm ci -CMD /home/app/docker-run.sh +CMD "/home/app/docker-run.sh" diff --git a/README.md b/README.md index 537bc6c..ae825b3 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ See the CONTRIBUTING file for information on helping out, or our [Wiki](https:// ### Prerequisites - Docker Compose v2 or higher (run `docker compose version` or `docker-compose version`) +- If using your own database server vs the builtin Docker, Postgres 15. ### ARM vs x86 Considerations @@ -26,7 +27,7 @@ docker compose up -d members_api -If you need to override some of the default environment variables or ports in from the docker-compose.yml file, you can create a docker-compose.override.yml and add your own environment variables or ports there. This file is automatically used by docker-compose and is ignored by git, so you can safely add it to your local repo. +If you need to override some of the default environment variables or ports in from the docker-compose.yml file, you can create a docker-compose.override.yml and add your own environment variables or ports there. > **Warning, this docker image is intended for dev mode** attached to a destroyable Postgres server and npm-install-able project folder. ***Running this in critical/prod environments is strongly discouraged without lots of testing!!!*** @@ -50,11 +51,18 @@ Review the `Dockerfile` so you know what's about to be booted. For example, the Create the docker container for the api and database: `docker compose up` -To access the container's shell prompt: +To access the container's shell prompt for the following npm commands: `docker exec -it members_api /bin/sh` -To create basic db tables from within container shell: -`npm run up` +If you **do** have an existing db and want to upgrade its schema to the latest structure: +`npm run db_migrate_latest` + +***OR*** + +If you **don't** have an existing db and want to create basic sample database structure and entries: +`npm run db_seed` + +Other npm tasks include db_migrate_rollback_all, db_migrate_down, db_migrate_up, and db_clear. To view this container's website from the docker host machine: `http://localhost:3004` @@ -68,9 +76,23 @@ You can build this container directly with: `docker build -t members_api .` You can run this container directly with: `docker run -it members_api /bin/sh` You'll then have to manually run commands like `npm install` or `npm run start` (see Dockerfile and docker-compose.yml for various assumptions and env vars we use during normal runtime.) +### Emails/SMTP + +[Inbucket](https://inbucket.org) is running on http://localhost:10001 by default in dev mode so that you can receive emails without actually sending anything. + +## Database + +Under Docker, a single container runs two databases; `members_api_db_development` and `members_api_db_test`. +The test database `members_api_db_test` is managed by Docker scripts, and is migrated up and down as part of each test run. +The development database is migrated each time it is booted up. +Migrations and data seeds against the development database may be run manually with the `./scripts/docker-exec.sh` script. + + - `docker exec members_api npm run db_migrate_up` + - `docker exec members_api npm run db_seed` + ## Tests -To run tests or coverage, we use lab: +To run tests or coverage, we use [lab](https://hapi.dev/module/lab/): - `npm run test` - `npm run coverage` @@ -78,9 +100,14 @@ To run tests or coverage, we use lab: Via Docker: - `docker-compose build` - - `docker-compose run -e NODE_ENV=test members_api` + - `NODE_ENV=test docker compose run --rm members_api` - `docker stop members_api_postgres && docker container rm members_api_postgres && docker volume rm members_api_db_data` + or one specific test only: + + - `docker-compose build` + - `NODE_ENV=test docker-compose run --rm members_api npm run test test/routes/events/browse.js` + ## Manual testing/usage Assuming your docker and/or node are on port 3004, you can run: @@ -104,6 +131,6 @@ Create an event: Delete an event: `curl -X DELETE -H "Authorization: Bearer $MY_AUTH_TOKEN" localhost:3004/events/YOUR_EVENT_ID_HERE` -## Emails/SMTP +## Heroku Deploy -[Inbucket](https://inbucket.org) is running on http://localhost:10001 by default in dev mode so that you can receive emails without actually sending anything. +- See `app.json` \ No newline at end of file diff --git a/app.json b/app.json new file mode 100644 index 0000000..12d1aa3 --- /dev/null +++ b/app.json @@ -0,0 +1,46 @@ +{ + "name": "Members API", + "description": "API for the Members App", + "repository": "https://github.com/heatsynclabs/members_api", + "env": { + "NODE_ENV": { + "description": "Run the app in production/development/test", + "value": "development" + }, + "JWT_KEY": { + "description": "Encryption key for JSON Web Tokens", + "value": "some random key" + }, + "PGSSLMODE": { + "description": "How to run Postgres SSL", + "value": "no-verify" + }, + "SENDGRID_API_KEY": { + "description": "API for sending emails when local methods are unavailable" + }, + "ADMIN_EMAIL": { + "description": "Address to send email from", + "value": "info@heatsynclabs.org" + }, + "SERVER_URL": { + "description": "Base URL of this API to link users to when sending them login emails, no trailing slash" + }, + "DOMAIN_LOCATION": { + "description": "Base URL of the UI to redirect users on login, no trailing slash" + }, + "DOMAIN_LOCATION_DEV": { + "description": "Base URL of the UI to redirect users in dev mode, no trailing slash" + }, + "DEV_COOKIES": { + "description": "1 or 0, to set cookie security weak or strong" + }, + "DEV_SAME_SITE": { + "description": "1 or 0, to set cookie same-site protection lax or none" + } + }, + "addons": [ + { + "plan": "heroku-postgresql" + } + ] +} diff --git a/config.js b/config.js index 2080cdf..2b5524c 100644 --- a/config.js +++ b/config.js @@ -16,10 +16,9 @@ const env = process.env.NODE_ENV || 'development'; const port = process.env.NODE_PORT || process.env.PORT || 3004; const { forEach, startsWith, lowerCase } = require('lodash'); const Pack = require('./package.json'); -const knexFile = require('./knexfile'); const jwt = { - password: process.env.JWT_KEY, + password: process.env.JWT_KEY || 'some random key', signOptions: {}, }; @@ -48,7 +47,7 @@ forEach(process.env, (v, k) => { } }); -const connectionOptions = { +const hapiServerOptions = { test: { host: '0.0.0.0', port, @@ -118,9 +117,9 @@ const cache = { module.exports = { env, - knex: knexFile, siteName: process.env.SITE_NAME || 'HeatSync Labs', - connection: connectionOptions[env], + hapiServerOptions: hapiServerOptions[env], + databaseUrl: process.env.DATABASE_URL, jwt, cookies, oauth, diff --git a/docker-compose.yml b/docker-compose.yml index eeb97f8..fe3b107 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,9 +28,9 @@ services: - "9229:9229" environment: NODE_PORT: 3004 - NODE_ENV: "development" + NODE_ENV: "${NODE_ENV:-development}" NPMINSTALL: 1 - DATABASE_URL: "postgres://postgres:postgres@members_api_postgres:5432/hsl_dev" + DATABASE_URL: "postgres://members_api_db_${NODE_ENV:-development}:members_api_db_${NODE_ENV:-development}@members_api_postgres:5432/members_api_db_${NODE_ENV:-development}" SMTP_HOST: "inbucket" SMTP_PORT: "10025" SMTP_USER: "user" @@ -45,14 +45,15 @@ services: members_api_postgres: container_name: members_api_postgres - image: postgres:11.2-alpine + image: postgres:15.1-alpine ports: - "5432:5432" environment: + POSTGRES_MULTIPLE_DATABASES: members_api_db_development,members_api_db_test POSTGRES_PASSWORD: postgres - POSTGRES_DB: hsl_dev volumes: - db_data:/var/lib/postgresql/data + - ./pg-init-scripts:/docker-entrypoint-initdb.d healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 5s diff --git a/docker-run.sh b/docker-run.sh index e0f8fda..1003938 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -1,17 +1,3 @@ -# Copyright 2019 Iced Development, LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - echo "Database URL is $DATABASE_URL" echo "NODE_ENV is $NODE_ENV" @@ -19,28 +5,27 @@ echo "Setting PATH from $PATH ..." export PATH=$PATH:$(pwd)/node_modules/.bin echo "... to $PATH" -if [[ $PORT ]] +if [ $PORT ] then export NODE_PORT=$PORT echo "Setting NODE_PORT=$PORT based on PORT" fi -if [[ $NPMINSTALL == 1 ]] + +if [ $NPMINSTALL == 1 ] then npm install fi -if [[ $NODE_ENV == "production" ]] +if [ $NODE_ENV == "production" ] then npm run start -elif [[ $NODE_ENV == "development" ]] +elif [ $NODE_ENV == "development" ] then - npm run up - npm run seed + npm run db_migrate_latest npm run develop -elif [[ $NODE_ENV == "test" ]] +elif [ $NODE_ENV == "test" ] then - npm run up + npm run db_migrate_rollback_all + npm run db_migrate_latest npm run test -else - echo "Improper NODE_ENV=$NODE_ENV, stopping" -fi \ No newline at end of file +fi diff --git a/index.js b/index.js index 5991f26..7b1985e 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ const handleErrors = require('./handleErrors'); const Routes = require('./routes'); // Declare a new instance of hapi -const server = Hapi.server(config.connection); +const server = Hapi.server(config.hapiServerOptions); // Start server function async function start() { diff --git a/knex.js b/knex.js index 57e7477..aa2d85f 100644 --- a/knex.js +++ b/knex.js @@ -12,6 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -const config = require('./config'); +const knexFile = require('./knexfile'); -module.exports = require('knex')(config.knex); +module.exports = require('knex')(knexFile); diff --git a/knexfile.js b/knexfile.js index 99f582c..bd82317 100644 --- a/knexfile.js +++ b/knexfile.js @@ -12,18 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -const config = { +const config = require('./config'); + +module.exports = { client: 'postgresql', - connection: process.env.DATABASE_URL || 'postgres://postgres@localhost:5432/hsl', + connection: config.databaseUrl, pool: { min: 1, max: 7, }, seeds: { - directory: './migrations/seed' - } + directory: './migrations/seed', + }, }; - -// console.log('knex', config); - -module.exports = config; diff --git a/lib/bread.js b/lib/bread.js index 819d59c..5d0d5a6 100644 --- a/lib/bread.js +++ b/lib/bread.js @@ -13,8 +13,8 @@ // limitations under the License. const breadfruit = require('breadfruit'); -const config = require('../config'); +const knexFile = require('../knexfile'); -const bread = breadfruit(config.knex); +const bread = breadfruit(knexFile); module.exports = bread; diff --git a/migrations/20180217155311_initial.js b/migrations/20180217155311_initial.js index bbf546c..bd029fa 100644 --- a/migrations/20180217155311_initial.js +++ b/migrations/20180217155311_initial.js @@ -13,26 +13,24 @@ // limitations under the License. const fs = require('fs'); -const knex = require('../knex') const up = fs.readFileSync('./migrations/up/20180217155311_initial.sql', 'utf8'); const down = fs.readFileSync('./migrations/down/20180217155311_initial.sql', 'utf8'); - -exports.up = async function(knexing, success) { +exports.up = async function (knex) { try { - await knex.raw(up) + await knex.raw(up); } catch (err) { - console.log('err', err); + console.error('err', err); return err; } }; -exports.down = async function(success, error) { +exports.down = async function (knex) { try { - await knex.raw(down) + await knex.raw(down); } catch (err) { - console.log('err', err); + console.error('err', err); return err; } }; diff --git a/migrations/seed/001_users.js b/migrations/seed/001_users.js deleted file mode 100644 index 2508684..0000000 --- a/migrations/seed/001_users.js +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -const bcrypt = require('bcrypt'); - -exports.seed = async function(knex) { - // Deletes ALL existing entries - await knex('users').del(); - - await knex('users').insert([ - {password: bcrypt.hashSync('Testing1!!', 10), email: "admin@example.com", name: "Admin", is_validated: true, member_level: 100}, - {password: bcrypt.hashSync('Testing1!!', 10), email: "gobie@example.com", name: "Gobie McDaniels", is_validated: true, member_level: 5}, - {password: bcrypt.hashSync('Testing1!!', 10), email: "jimbo@example.com", name: "Jimbo Fargo", is_validated: false, member_level: 1}, - {password: bcrypt.hashSync('Testing1!!', 10), email: "hardy@example.com", name: "Hardy Bridle", is_validated: true, member_level: 10}, - ]); -}; diff --git a/migrations/seed/001_welcome.js b/migrations/seed/001_welcome.js new file mode 100644 index 0000000..0a81e8f --- /dev/null +++ b/migrations/seed/001_welcome.js @@ -0,0 +1,88 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +const bcrypt = require('bcrypt'); +const config = require('../../config'); + +const seedUserPassword = 'Testing1!!'; +const hashedSeedUserPassword = bcrypt.hashSync(seedUserPassword, 10); + +exports.seed = async (knex) => { + // Create a handful of users, including an Admin user + await knex('users') + .insert([ + { + password: hashedSeedUserPassword, + email: config.admin_email, + name: 'Admin', + is_validated: true, + member_level: 100, + }, + { + password: hashedSeedUserPassword, + email: 'gobie@example.com', + name: 'Gobie McDaniels', + is_validated: true, + member_level: 5, + }, + { + password: hashedSeedUserPassword, + email: 'jimbo@example.com', + name: 'Jimbo Fargo', + is_validated: false, + member_level: 1, + }, + { + password: hashedSeedUserPassword, + email: 'hardy@example.com', + name: 'Hardy Bridle', + is_validated: true, + member_level: 10, + }, + ]) + .onConflict('email') + .merge(); + + // Create the ADMIN group, to which the Admin user will belong + await knex('groups') + .insert({ id: 'ADMIN' }) + .onConflict('id') + .merge(); + + // Find the Admin user, to make sure we have the correct id + const { id: adminUserId } = await knex('users') + .where('email', config.admin_email) + .first('id'); + + // Add the Admin user to the ADMIN group + await knex('memberships') + .insert([ + { user_id: adminUserId, group_id: 'ADMIN' }, + ]) + .onConflict(['group_id', 'user_id']) + .merge(); + + // Create the Laser Class; + // first try and find an existing Admin Laser Class, + // then create one if it doesn't already exist + const adminLaserClass = await knex('events') + .where({ created_by: adminUserId, name: 'Laser Class' }) + .select(['id']) + .first(); + + if (!adminLaserClass) { + await knex('events') + .insert([ + { + name: 'Laser Class', + description: "Join this class!\r\nIt's fun!", + start_date: '2019-10-11 13:00:00', + end_date: '2019-10-11 15:00:00', + frequency: 'weekly', + location: 'HeatSync Labs', + created_by: adminUserId, + }, + ]); + } +}; diff --git a/migrations/seed/002_membership.js b/migrations/seed/002_membership.js deleted file mode 100644 index 66d0512..0000000 --- a/migrations/seed/002_membership.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * @param { import("knex").Knex } knex - * @returns { Promise } - */ -exports.seed = async function(knex) { - await knex('memberships').del(); - const {id} = await knex('users').where('email', 'admin@example.com').first('id'); - await knex('memberships').insert([{user_id: id, group_id: 'ADMIN'}]); -}; diff --git a/migrations/seed/003_events.js b/migrations/seed/003_events.js index 2d61d1f..6b03b4d 100644 --- a/migrations/seed/003_events.js +++ b/migrations/seed/003_events.js @@ -1,3 +1,5 @@ +const { faker } = require('@faker-js/faker'); + /** * @param { import("knex").Knex } knex * @returns { Promise } @@ -5,7 +7,15 @@ exports.seed = async function(knex) { await knex('events').del() const {id} = await knex('users').where('email', 'admin@example.com').first('id'); + + let date1 = faker.date.soon(); + let date2 = date1; + date2.setHours(date1.getHours()+1); + let date3 = faker.date.soon(); + let date4 = date3; + date4.setHours(date3.getHours()+1); await knex('events').insert([ - {name: "Laser Class", description: "Join this class!\r\nIt's fun!", start_date: "2019-10-11 13:00:00", end_date: "2019-10-11 15:00:00", frequency: "weekly", location: "HeatSync Labs", created_by: id}, + {name: "Laser Class", description: "Join this class!\r\nIt's fun!", start_date: date1, end_date: date2, frequency: "weekly", location: "HeatSync Labs", created_by: id}, + {name: "3D Printing Class", description: "Join this class!\r\nLiterally the most fun you will ever have.
I promise.", start_date: date3, end_date: date4, frequency: "biweekly", location: "HeatSync Labs", created_by: id}, ]); }; diff --git a/models/events.js b/models/events.js index 5ced221..03fa44f 100644 --- a/models/events.js +++ b/models/events.js @@ -6,14 +6,18 @@ const model = breadModel({ softDelete: true, schema: { id: Joi.string().uuid().required(), - name: Joi.string(), - description: Joi.string(), - start_date: Joi.date(), - end_date: Joi.date(), + name: Joi.string().required(), + description: Joi.string().allow(null), + start_date: Joi.date().required(), + end_date: Joi.date().allow(null), location: Joi.string(), frequency: Joi.string(), + }, + viewOnly: { + created_at: Joi.date(), + updated_at: Joi.date().allow(null), is_deleted: Joi.boolean(), - deleted_at: Joi.date(), + deleted_at: Joi.date().allow(null), created_by: Joi.string().uuid().required(), }, }); diff --git a/package-lock.json b/package-lock.json index 062bae3..edf4e63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-react": "^7.31.11", + "knex-schema-inspector": "^3.0.0", "lab": "^18.0.2", "nodemon": "^2.0.20", "nyc": "^15.1.0", @@ -5597,6 +5598,16 @@ } } }, + "node_modules/knex-schema-inspector": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/knex-schema-inspector/-/knex-schema-inspector-3.0.0.tgz", + "integrity": "sha512-J3Aeh4kIZD2sYUjnpak+sdVtegGVkkhCF5/VSziocSZpseW4MwTcnxHbttsaT3tA85INuPxxUBxFgyCm9tMZDQ==", + "dev": true, + "dependencies": { + "lodash.flatten": "^4.4.0", + "lodash.isnil": "^4.0.0" + } + }, "node_modules/knex/node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -6209,6 +6220,12 @@ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "dev": true + }, "node_modules/lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", @@ -14227,6 +14244,16 @@ } } }, + "knex-schema-inspector": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/knex-schema-inspector/-/knex-schema-inspector-3.0.0.tgz", + "integrity": "sha512-J3Aeh4kIZD2sYUjnpak+sdVtegGVkkhCF5/VSziocSZpseW4MwTcnxHbttsaT3tA85INuPxxUBxFgyCm9tMZDQ==", + "dev": true, + "requires": { + "lodash.flatten": "^4.4.0", + "lodash.isnil": "^4.0.0" + } + }, "lab": { "version": "18.0.2", "resolved": "https://registry.npmjs.org/lab/-/lab-18.0.2.tgz", @@ -14710,6 +14737,12 @@ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" }, + "lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "dev": true + }, "lodash.isnumber": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", diff --git a/package.json b/package.json index 81351aa..d883c04 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "start": "knex migrate:latest && node index.js", "inspect": "nodemon --inspect index.js", "develop": "DEBUG=error*,general* nodemon --trace-warnings index", - "test": "NODE_ENV=test lab -v --leaks", + "test": "lab -v --leaks", "lint": "eslint . --ext .js", - "coverage": "NODE_ENV=test nyc cover lab -v --leaks", - "up": "knex migrate:latest", - "down": "knex migrate:rollback", - "seed": "knex seed:run" + "coverage": "nyc cover lab -v --leaks", + "db_migrate_up": "knex migrate:up", + "db_migrate_down": "knex migrate:down", + "db_migrate_latest": "knex migrate:latest", + "db_migrate_rollback_all": "knex migrate:rollback --all", + "db_seed": "knex seed:run" }, "dependencies": { "@hapi/cookie": "^12.0.0", @@ -56,6 +58,7 @@ "eslint-plugin-import": "^2.26.0", "eslint-plugin-jsx-a11y": "^6.6.1", "eslint-plugin-react": "^7.31.11", + "knex-schema-inspector": "^3.0.0", "lab": "^18.0.2", "nodemon": "^2.0.20", "nyc": "^15.1.0", diff --git a/pg-init-scripts/Dockerfile b/pg-init-scripts/Dockerfile new file mode 100644 index 0000000..9e4b59a --- /dev/null +++ b/pg-init-scripts/Dockerfile @@ -0,0 +1,2 @@ +FROM postgres:9.6 +COPY create-postgres-databases.sh /docker-entrypoint-initdb.d/ diff --git a/pg-init-scripts/create-postgres-databases.sh b/pg-init-scripts/create-postgres-databases.sh new file mode 100755 index 0000000..ac6bbe6 --- /dev/null +++ b/pg-init-scripts/create-postgres-databases.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e +set -u + +function create_user_and_database() { + local database=$1 + echo "💌 Creating user and database '$database'" + psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE USER $database WITH PASSWORD '$database'; + CREATE DATABASE $database WITH OWNER $database; + GRANT ALL PRIVILEGES ON DATABASE $database TO $database; +EOSQL +} + +if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then + echo "❤️ Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES" + for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do + create_user_and_database $db + done + echo "❤️ Multiple databases created" +fi diff --git a/routes/events.js b/routes/events.js index c22f607..5649e14 100644 --- a/routes/events.js +++ b/routes/events.js @@ -60,7 +60,7 @@ module.exports = [ config: { auth: { strategies: ['auth', 'jwt'], - scope: ['USER'], + scope: ['ADMIN'], }, handler: (req) => model.remove(req.params.event_id), description: 'Deletes an Event', @@ -83,7 +83,7 @@ module.exports = [ config: { auth: { strategies: ['auth', 'jwt'], - scope: ['USER'], + scope: ['ADMIN'], }, description: 'Add An Event', notes: 'Adds an Event', @@ -94,13 +94,13 @@ module.exports = [ }, }, { - method: 'PATCH', + method: 'PUT', path: '/events/{event_id}', handler: (req) => model.edit(req.params.event_id, req.payload), config: { auth: { strategies: ['auth', 'jwt'], - scope: ['USER'], + scope: ['ADMIN'], }, description: 'Edit An Event', notes: 'Edit an Event', @@ -109,7 +109,12 @@ module.exports = [ params: Joi.object({ event_id: model.schema.id }), - payload: Joi.object(omit(model.schema, ['id', 'created_by'])), + payload: Joi.object(omit(model.schema, ['id', 'created_by',])), + // failAction: async (request, h, err) => { + // console.log('SPEZIAL: validation error'); + // console.log(request.payload); + // console.log(err); + // } }, }, }, diff --git a/test/clearDb.js b/test/clearDb.js new file mode 100644 index 0000000..7494448 --- /dev/null +++ b/test/clearDb.js @@ -0,0 +1,28 @@ +const { SchemaInspector } = require('knex-schema-inspector'); +const Debug = require('debug'); +const knex = require('../knex'); + +const debug = Debug('clearDb'); + +const inspector = SchemaInspector(knex); + +async function resetDb() { + debug('Removing all db records'); + + const tableNames = (await inspector.tables()).filter((tableName) => { + return !tableName.startsWith('knex_'); + }); + + while (tableNames.length) { + const tableName = tableNames.shift(); + try { + debug('Removing db records from table %s', tableName); + await knex(tableName).del(); + } catch (error) { + debug('Failed to remove db records from table %s; re-queueing', tableName); + tableNames.push(tableName); + } + } +} + +module.exports = resetDb; diff --git a/test/fixture-client.js b/test/fixture-client.js index bc35739..7e53a2d 100644 --- a/test/fixture-client.js +++ b/test/fixture-client.js @@ -17,6 +17,7 @@ const { forEach, keys, values } = require('lodash'); const promise = require('bluebird'); const debug = require('debug')('errors'); const url = require('url'); +const { Factory } = require('rosie'); const server = require('..'); const { connection } = require('../knexfile'); const knex = require('../knex'); @@ -30,17 +31,20 @@ const fixtures = new Fixtures(fixturesConfig); const tableDeleteOrder = [ 'time_token', + 'memberships', 'events', 'users', ]; const tableToDeleteKey = { time_token: 'id', - events: 'id', + memberships: 'user_id', + events: 'name', users: 'email' }; async function mapRelation(relationValue) { + console.warn('mapRelation:', relationValue); const [tableName, offset] = relationValue.split(':'); return (await knex(tableName).offset(offset).first('id')).id; } @@ -50,6 +54,7 @@ async function mapRelation(relationValue) { // collection of untransformed fixtures and replaces the `created_by` field // of each one with the corresponding user id from the database. function createMapRelations(relationNames) { + console.warn('Mapping relation:', relationNames); return async function mapRelations(collection) { return Promise.all(collection.map(async (item) => ({ ...item, @@ -94,6 +99,26 @@ module.exports = { }); }); }, + async makeUserIdAdmin(userId) { + await knex('groups') + .insert({ id: 'ADMIN', description: 'Admin users' }) + .onConflict('id') + .merge(); + + const membership = new Factory(); + membership + .attr('user_id') + .attr('group_id'); + + const memberships = [ + // user 1 will be an admin + membership.build({ + user_id: userId, + group_id: 'ADMIN' + }), + ]; + await knex('memberships').insert(memberships); + }, async destroyTokens(userIds) { return promise.each(userIds, (id) => knex('time_token') .where('user_id', id) diff --git a/test/fixtures/events.js b/test/fixtures/events.js index f924fcb..fca92f9 100644 --- a/test/fixtures/events.js +++ b/test/fixtures/events.js @@ -12,29 +12,46 @@ // See the License for the specific language governing permissions and // limitations under the License. +function capitalize(word) +{ + return word.charAt(0).toUpperCase() + word.slice(1); +} + const { Factory } = require('rosie'); +const { faker } = require('@faker-js/faker'); const event = new Factory(); event - .attr('id') + // .attr('id') .attr('name') // .attr('description') .attr('start_date') // .attr('end_date') .attr('frequency') - .attr('location'); + .attr('location') + .attr('created_by'); const fixture = [ event.build({ // id: '44fecd99-3400-449a-b13c-61ad7ffd1d11', - name: 'Laser Class', + name: `${capitalize(faker.word.verb())} ${capitalize(faker.word.noun())}`, // description: 'Join this class!\r\nIt\'s fun!', - start_date: new Date('2019-10-11 13:00:00'), + start_date: faker.date.soon(), // end_date: '2019-10-11 15:00:00', frequency: 'weekly', location: 'HeatSync Labs', - created_by: 'users:0', + // created_by: 'users:0', + }), + event.build({ + // id: '44fecd99-3400-449a-b13c-61ad7ffd1d11', + name: `${capitalize(faker.word.verb())} ${capitalize(faker.word.noun())}`, + // description: 'Join this class!\r\nIt\'s fun!', + start_date: faker.date.soon(), + // end_date: '2019-10-11 15:00:00', + frequency: 'biweekly', + location: 'HeatSync Labs', + // created_by: 'users:0', }), ]; diff --git a/test/fixtures/index.js b/test/fixtures/index.js index 8965d50..39515ec 100644 --- a/test/fixtures/index.js +++ b/test/fixtures/index.js @@ -17,6 +17,6 @@ module.exports = { users: require('./users'), events: require('./events'), time_token: require('./time_token'), - tokens: require('./tokens') + tokens: require('./tokens'), /* eslint-enable */ }; diff --git a/test/fixtures/users.js b/test/fixtures/users.js index 9d0ab0a..60fdb4f 100644 --- a/test/fixtures/users.js +++ b/test/fixtures/users.js @@ -19,29 +19,27 @@ const user = new Factory(); user // .attr('id') + .attr('name') .attr('password', '$2a$10$.KyBD1VevOUePkvAE/qDjufhc7dmvjrLsTsCa/As/PDBG.Hmh.ZCq') .attr('email', faker.internet.email) .attr('is_validated') .attr('is_deleted'); const fixture = [ + // user 1 will be an admin user.build({ // id: '44fecd99-3400-449a-b13c-61ad7ffd1d71', is_validated: true, is_deleted: false, }), + // user 2+ won't be user.build({ // id: '44fecd99-3400-449a-b13c-61ad7ffd1d72', is_validated: true, - is_deleted: true, - }), - user.build({ - // id: '44fecd99-3400-449a-b13c-61ad7ffd1d73', - is_validated: false, is_deleted: false, }), user.build({ - // id: '44fecd99-3400-449a-b13c-61ad7ffd1d74', + // id: '44fecd99-3400-449a-b13c-61ad7ffd1d73', is_validated: false, is_deleted: true, }), diff --git a/test/routes/events/add.js b/test/routes/events/add.js index 332dfd9..4f5f431 100644 --- a/test/routes/events/add.js +++ b/test/routes/events/add.js @@ -18,29 +18,33 @@ const lab = exports.lab = require('lab').script(); const url = require('url'); const server = require('../../..'); -const { destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken, makeUserIdAdmin } = require('../../fixture-client'); const { users } = require('../../fixtures'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); lab.experiment('POST /events', () => { let Authorization; + let myUserId; lab.before(async () => { - await knex('users').insert(users); + const insertedUserIds = await knex('users').insert(users).returning(['id']); + myUserId = insertedUserIds[0].id; + + await makeUserIdAdmin(myUserId); + const authRes = await getAuthToken(users[0]); Authorization = authRes.token; }); - lab.after(() => { - return destroyRecords({ users }); + lab.after(async () => { + await clearDb(); }); - lab.test('should create an event', async () => { + lab.test('should create an event with appropriate created_by', async () => { const e = { - name: 'Laser Class Test', - // description: 'Join this class!\r\nIt\'s fun!', + name: 'Laser Class Testy', start_date: new Date('2019-11-11 13:00:00'), - // end_date: '2019-10-11 15:00:00', frequency: 'weekly', location: 'HeatSync Labs', }; @@ -56,5 +60,96 @@ lab.experiment('POST /events', () => { expect(res.statusCode).to.equal(200); expect(res.result).to.be.an.object(); expect(res.result).to.include('id'); + + // now get + const options2 = { + url: url.format({ + pathname: '/events', + }), + method: 'GET', + headers: { Authorization }, + }; + + const res2 = await server.inject(options2); + expect(res2.statusCode).to.equal(200); + expect(res2.result).to.be.an.array(); + expect(res2.result[res2.result.length - 1].name).to.equal('Laser Class Testy'); + expect(res2.result[res2.result.length - 1].created_by).to.equal(myUserId); + expect(res2.result[res2.result.length - 1].created_at).to.not.equal(null); + expect(res2.result[res2.result.length - 1].deleted_at).to.equal(null); + expect(res2.result[res2.result.length - 1].is_deleted).to.equal(false); + }); + + lab.test('should error with missing name', async () => { + const e = { + start_date: new Date('2019-11-11 13:00:00'), + frequency: 'weekly', + location: 'HeatSync Labs', + }; + + const options = { + url: url.format('/events'), + method: 'POST', + payload: e, + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(400); + }); + + lab.test('should error with missing start_date', async () => { + const e = { + name: 'Laser Class Test', + frequency: 'weekly', + location: 'HeatSync Labs', + }; + + const options = { + url: url.format('/events'), + method: 'POST', + payload: e, + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(400); + }); + + lab.test('should error if protected details like created_by are provided', async () => { + const e = { + name: 'Laser Class Test', + frequency: 'weekly', + location: 'HeatSync Labs', + created_by: 'users:0', + }; + + const options = { + url: url.format('/events'), + method: 'POST', + payload: e, + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(400); + }); + + lab.test('should error with no auth', async () => { + const e = { + name: 'Laser Class Test', + start_date: new Date('2019-11-11 13:00:00'), + frequency: 'weekly', + location: 'HeatSync Labs', + }; + + const options = { + url: url.format('/events'), + method: 'POST', + payload: e, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(401); }); }); diff --git a/test/routes/events/browse.js b/test/routes/events/browse.js index 28c08ff..cb82317 100644 --- a/test/routes/events/browse.js +++ b/test/routes/events/browse.js @@ -18,8 +18,9 @@ const lab = exports.lab = require('lab').script(); const url = require('url'); const server = require('../../..'); -const { createMapRelations, destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken } = require('../../fixture-client'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); const { users, events } = require('../../fixtures'); @@ -28,21 +29,13 @@ lab.experiment('GET /events/', () => { lab.before(async () => { await knex('users').insert(users); - await knex('events').insert(await createMapRelations(['created_by'])(events)); + await knex('events').insert(events); const authRes = await getAuthToken(users[0]); Authorization = authRes.token; }); lab.after(async () => { - const usersToDestroy = await knex('users').select('email', 'id'); - const eventsToDestroy = await knex('events') - .select('id', 'created_by') - .where((builder) => builder.whereIn('created_by', usersToDestroy.map(({ id }) => id))); - - await destroyRecords({ - users: usersToDestroy, - events: eventsToDestroy - }); + await clearDb(); }); lab.test('should retrieve event information when logged in', (done) => { diff --git a/test/routes/events/delete.js b/test/routes/events/delete.js index eccf464..ac0b420 100644 --- a/test/routes/events/delete.js +++ b/test/routes/events/delete.js @@ -19,30 +19,28 @@ const url = require('url'); const server = require('../../..'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); -const { createMapRelations, destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken, makeUserIdAdmin } = require('../../fixture-client'); const { users, events } = require('../../fixtures'); lab.experiment('DELETE /events/', () => { let Authorization; + let myUserId; lab.before(async () => { - await knex('users').insert(users); - await knex('events').insert(await createMapRelations(['created_by'])(events)); + const insertedUserIds = await knex('users').insert(users).returning(['id']); + await knex('events').insert(events); + myUserId = insertedUserIds[0].id; + + await makeUserIdAdmin(myUserId); + const authRes = await getAuthToken(users[0]); Authorization = authRes.token; }); lab.after(async () => { - const usersToDestroy = await knex('users').select('email', 'id'); - const eventsToDestroy = await knex('events') - .select('id', 'created_by') - .where((builder) => builder.whereIn('created_by', usersToDestroy.map(({ id }) => id))); - - await destroyRecords({ - users: usersToDestroy, - events: eventsToDestroy - }); + await clearDb(); }); lab.test('should successfully delete an event', async () => { diff --git a/test/routes/events/edit.js b/test/routes/events/edit.js index 540c0ca..d8b9418 100644 --- a/test/routes/events/edit.js +++ b/test/routes/events/edit.js @@ -19,45 +19,43 @@ const url = require('url'); const { omit } = require('lodash'); const server = require('../../..'); -const { createMapRelations, destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken, makeUserIdAdmin } = require('../../fixture-client'); const { users, events } = require('../../fixtures'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); -lab.experiment('PATCH /events/', () => { +lab.experiment('PUT /events/', () => { let Authorization; lab.before(async () => { - await knex('users').insert(users); - await knex('events').insert(await createMapRelations(['created_by'])(events)); + const insertedUserIds = await knex('users').insert(users).returning(['id']); + const myUserId = insertedUserIds[0].id; + await knex('events').insert(events); + + await makeUserIdAdmin(myUserId); + const authRes = await getAuthToken(users[0]); Authorization = authRes.token; }); lab.after(async () => { - const usersToDestroy = await knex('users').select('email', 'id'); - const eventsToDestroy = await knex('events') - .select('id', 'created_by') - .where((builder) => builder.whereIn('created_by', usersToDestroy.map(({ id }) => id))); - - await destroyRecords({ - users: usersToDestroy, - events: eventsToDestroy - }); + await clearDb(); }); lab.test('should successfully edit an event', async () => { - const sampleEvent = await knex('events').first('id', 'is_deleted'); - sampleEvent.name = 'fookie'; + const eventsToEdit = await knex('events'); + eventsToEdit[0].name = 'fookie'; + const options = { - url: url.format(`/events/${sampleEvent.id}`), - method: 'PATCH', + url: url.format(`/events/${eventsToEdit[0].id}`), + method: 'PUT', headers: { Authorization }, - payload: omit(sampleEvent, ['id', 'created_by']), + payload: omit(eventsToEdit[0], ['id', 'created_by', 'created_at', 'updated_at', 'deleted_at', 'is_deleted']), }; const res = await server.inject(options); expect(res.statusCode).to.equal(200); - expect(res.result.id).to.equal(sampleEvent.id); + expect(res.result.id).to.equal(eventsToEdit[0].id); expect(res.result.name).to.equal('fookie'); }); }); diff --git a/test/routes/events/read.js b/test/routes/events/read.js new file mode 100644 index 0000000..55ca07a --- /dev/null +++ b/test/routes/events/read.js @@ -0,0 +1,90 @@ +// Copyright 2019 Iced Development, LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +const { expect } = require('code'); +// eslint-disable-next-line +const lab = exports.lab = require('lab').script(); +const url = require('url'); + +const server = require('../../..'); +const { getAuthToken } = require('../../fixture-client'); +const knex = require('../../../knex'); +const { users, events } = require('../../fixtures'); +const clearDb = require('../../clearDb'); + +lab.experiment('GET /events/{event_id}', () => { + let Authorization; + + lab.before(async () => { + await knex('users').insert(users); + await knex('events').insert(events); + const authRes = await getAuthToken(users[0]); + Authorization = authRes.token; + }); + + lab.after(async () => { + await clearDb(); + }); + + lab.test('should return a event by id', async () => { + const event = await knex('events').offset(0).first('id', 'name'); + const options = { + url: url.format(`/events/${event.id}`), + method: 'GET', + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(200); + expect(res.result).to.be.an.object(); + expect(res.result.id).to.equal(event.id); + expect(res.result.name).to.equal(event.name); + }); + + // TODO: this explodes violently inside hapi when it should return a nice 404 + lab.test.skip('should error if event is not found', async () => { + const event = await knex('events').offset(0).first('id'); + const options = { + url: url.format('/events/badbadf7-53a7-4d66-abf5-541d3ed767d0'), + method: 'GET', + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(404); + }); + + lab.test('should error with bad event id', async () => { + const event = await knex('events').offset(0).first('id'); + const options = { + url: url.format('/events/badbadbad'), + method: 'GET', + headers: { Authorization }, + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(400); + }); + + lab.test('should error if no auth token is found', async () => { + const event = await knex('events').offset(0).first('id'); + const options = { + url: url.format(`/events/${event.id}`), + method: 'GET', + }; + + const res = await server.inject(options); + expect(res.statusCode).to.equal(401); + }); +}); diff --git a/test/routes/users/add.js b/test/routes/users/add.js index cfc5317..17ec9a9 100644 --- a/test/routes/users/add.js +++ b/test/routes/users/add.js @@ -21,16 +21,15 @@ const { omit } = require('lodash'); const bread = require('../../../lib/bread'); const server = require('../../..'); -const { destroyRecords, destroyTokens } = require('../../fixture-client'); const { users } = require('../../fixtures'); +const clearDb = require('../../clearDb'); lab.experiment('POST /user', () => { // eslint-disable-next-line let tokens = []; - lab.after(() => { - return destroyRecords({ users }) - .then(destroyTokens(tokens)); + lab.after(async () => { + await clearDb(); }); lab.test('should create a user', async () => { diff --git a/test/routes/users/browse.js b/test/routes/users/browse.js index decacb3..90a6c5f 100644 --- a/test/routes/users/browse.js +++ b/test/routes/users/browse.js @@ -18,9 +18,10 @@ const lab = exports.lab = require('lab').script(); const url = require('url'); const server = require('../../..'); -const { destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken } = require('../../fixture-client'); const { users } = require('../../fixtures'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); lab.experiment('GET /users/', () => { let user; @@ -32,8 +33,8 @@ lab.experiment('GET /users/', () => { Authorization = authRes.token; }); - lab.after(() => { - return destroyRecords({ users }); + lab.after(async () => { + await clearDb(); }); lab.test('should retrieve my information when logged in', (done) => { diff --git a/test/routes/users/delete.js b/test/routes/users/delete.js index 784d428..cfc1b66 100644 --- a/test/routes/users/delete.js +++ b/test/routes/users/delete.js @@ -18,9 +18,10 @@ const lab = exports.lab = require('lab').script(); const url = require('url'); const server = require('../../..'); -const { destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken } = require('../../fixture-client'); const { users } = require('../../fixtures'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); lab.experiment('DELETE /users/', () => { let Authorization; @@ -31,8 +32,8 @@ lab.experiment('DELETE /users/', () => { Authorization = authRes.token; }); - lab.after(() => { - return destroyRecords({ users }); + lab.after(async () => { + await clearDb(); }); lab.test('should fail if trying to delete an unauthorized user', async () => { diff --git a/test/routes/users/read.js b/test/routes/users/read.js index 371ec71..1ad41f8 100644 --- a/test/routes/users/read.js +++ b/test/routes/users/read.js @@ -18,9 +18,10 @@ const lab = exports.lab = require('lab').script(); const url = require('url'); const server = require('../../..'); -const { destroyRecords, getAuthToken, fixtures } = require('../../fixture-client'); +const { getAuthToken } = require('../../fixture-client'); const { users } = require('../../fixtures'); const knex = require('../../../knex'); +const clearDb = require('../../clearDb'); lab.experiment('GET /users/{user_id}', () => { let Authorization; @@ -31,8 +32,8 @@ lab.experiment('GET /users/{user_id}', () => { Authorization = authRes.token; }); - lab.after(() => { - return destroyRecords({ users }); + lab.after(async () => { + await clearDb(); }); lab.test('should return a user by id', async () => {