├── .nvmrc ├── test ├── scalability.js ├── lib │ ├── mockAuth.js │ └── mockData.js ├── integration │ ├── health.test.js │ ├── accessCode.test.js │ ├── users.test.js │ ├── login.test.js │ ├── organizations.test.js │ └── case.test.js └── unit │ └── sampleTest.test.js ├── OWNERS ├── .database.env.template ├── app ├── api │ ├── auth │ │ ├── index.js │ │ ├── controller.js │ │ └── router.js │ ├── health │ │ ├── index.js │ │ ├── router.js │ │ └── controller.js │ ├── accessCode │ │ ├── index.js │ │ ├── router.js │ │ └── controller.js │ ├── organization │ │ ├── index.js │ │ ├── router.js │ │ └── controller.js │ └── case │ │ ├── index.js │ │ ├── router.js │ │ └── controller.js ├── lib │ ├── db.js │ ├── utils.js │ ├── random.js │ ├── auth.js │ ├── userManagement.js │ ├── writePublishedFiles.js │ ├── writeToS3Bucket.js │ ├── policy.js │ ├── writeToGCSBucket.js │ └── publicationFiles.js └── auth │ ├── index.js │ ├── enforce │ ├── index.js │ ├── test.js │ ├── prod.js │ └── common.js │ └── utils.js ├── .prettierignore ├── .github ├── CODEOWNERS └── workflows │ ├── google-cloud-run.yaml │ ├── integration-tests.yaml │ └── google-express-tests.yaml ├── .prettierrc ├── base ├── env │ ├── prod │ │ ├── kustomization.yml │ │ └── deployment.yml │ └── test │ │ ├── kustomization.yml │ │ └── deployment.yml └── deployment.yml ├── jsconfig.json ├── bin └── www ├── kustomization.yaml ├── deployment-configs ├── supervisor.pm2.conf ├── nginx-http.conf └── nginx-app.conf ├── .eslintrc ├── dbsetup.sh ├── tests ├── README.md └── tests │ ├── FASTAPI.postman_environment.json │ └── backend_collection.json ├── app.js ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── .gcloudignore ├── .env.template ├── README.md ├── cloudbuild.yaml ├── .gitignore ├── wait-for.sh ├── changelog.md ├── package.json ├── CONTRIBUTING.md ├── tools └── validateEnv.js ├── env.schema.json └── openapi.yaml /.nvmrc: -------------------------------------------------------------------------------- 1 | 13.1.0 2 | -------------------------------------------------------------------------------- /test/scalability.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | approvers: 2 | - krishnadurai 3 | - sublet 4 | -------------------------------------------------------------------------------- /.database.env.template: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=spdev 2 | POSTGRES_PASSWORD=safepaths 3 | -------------------------------------------------------------------------------- /app/api/auth/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | router: require('./router'), 3 | }; 4 | -------------------------------------------------------------------------------- /app/api/health/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | router: require('./router'), 3 | }; 4 | -------------------------------------------------------------------------------- /app/api/accessCode/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | router: require('./router'), 3 | }; 4 | -------------------------------------------------------------------------------- /app/api/organization/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | router: require('./router'), 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .nyc_output/ 2 | node_modules/ 3 | coverage/ 4 | tests/tests/newman-results.json 5 | -------------------------------------------------------------------------------- /app/lib/db.js: -------------------------------------------------------------------------------- 1 | const db = require('@pathcheck/data-layer'); 2 | 3 | module.exports = db.private; 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Defines individuals responsible for the repository code 2 | 3 | * @MarshallMcCoy @aiyan @sublet 4 | -------------------------------------------------------------------------------- /app/api/case/index.js: -------------------------------------------------------------------------------- 1 | // app/api/case/index.js 2 | 3 | module.exports = { 4 | router: require('./router'), 5 | }; 6 | -------------------------------------------------------------------------------- /app/auth/index.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils'); 2 | const enforce = require('./enforce'); 3 | 4 | module.exports = { 5 | utils, 6 | enforce, 7 | }; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "arrowParens": "avoid", 5 | "trailingComma": "all", 6 | "singleQuote": true, 7 | "quoteProps": "consistent" 8 | } -------------------------------------------------------------------------------- /base/env/prod/kustomization.yml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - deployment.yml 5 | images: 6 | - name: gcr.io/PROJECT_ID/IMAGE:TAG 7 | -------------------------------------------------------------------------------- /base/env/test/kustomization.yml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - deployment.yml 5 | images: 6 | - name: gcr.io/PROJECT_ID/IMAGE:TAG 7 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "experimentalDecorators": true 6 | }, 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /app/auth/enforce/index.js: -------------------------------------------------------------------------------- 1 | const prod = require('./prod'); 2 | const test = require('./test'); 3 | const { verifyRequest } = require('./common'); 4 | 5 | module.exports = { 6 | verifyRequest, 7 | prod, 8 | test, 9 | }; 10 | -------------------------------------------------------------------------------- /bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Environment variable validation 5 | */ 6 | require('dotenv').config() 7 | require('../tools/validateEnv')(process.env); 8 | 9 | const server = require('../app'); 10 | 11 | server.start() 12 | -------------------------------------------------------------------------------- /kustomization.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kustomize.config.k8s.io/v1beta1 2 | kind: Kustomization 3 | resources: 4 | - base/deployment.yml 5 | images: 6 | - name: gcr.io/PROJECT_ID/IMAGE:TAG 7 | newName: gcr.io/es-internals/safeplaces-be-express-test 8 | newTag: latest 9 | -------------------------------------------------------------------------------- /deployment-configs/supervisor.pm2.conf: -------------------------------------------------------------------------------- 1 | [program:pm2] 2 | directory=/app 3 | command=node ./bin/www 4 | priority=6 5 | autorestart=true 6 | stdout_logfile=/dev/stdout 7 | stdout_logfile_maxbytes=0 8 | stderr_logfile=/dev/stderr 9 | stderr_logfile_maxbytes=0 10 | -------------------------------------------------------------------------------- /app/api/accessCode/router.js: -------------------------------------------------------------------------------- 1 | const { router } = require('../../../app'); 2 | const controller = require('./controller'); 3 | 4 | router.post( 5 | '/access-code', 6 | router.wrapAsync( 7 | async (req, res) => await controller.generate(req, res), 8 | true, 9 | ), 10 | ); 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es6": true, 4 | "node": true, 5 | "mocha": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2018, 10 | "sourceType": "module" 11 | }, 12 | "rules": { 13 | "no-empty": "off" 14 | } 15 | } -------------------------------------------------------------------------------- /app/lib/utils.js: -------------------------------------------------------------------------------- 1 | const getMin = (data, key) => { 2 | return data.reduce((min, p) => (p[key] < min ? p[key] : min), data[0][key]); 3 | }; 4 | 5 | const getMax = (data, key) => { 6 | return data.reduce((max, p) => (p[key] > max ? p[key] : max), data[0][key]); 7 | }; 8 | 9 | module.exports = { 10 | getMin, 11 | getMax, 12 | }; 13 | -------------------------------------------------------------------------------- /test/lib/mockAuth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | function getAccessToken(idmId, role) { 4 | const ns = process.env.AUTH0_CLAIM_NAMESPACE; 5 | return jwt.sign( 6 | { 7 | sub: idmId, 8 | [`${ns}/roles`]: [role], 9 | }, 10 | process.env.JWT_SECRET, 11 | { 12 | algorithm: 'HS256', 13 | expiresIn: '1h', 14 | }, 15 | ); 16 | } 17 | 18 | module.exports = { getAccessToken }; 19 | -------------------------------------------------------------------------------- /deployment-configs/nginx-http.conf: -------------------------------------------------------------------------------- 1 | # Default nginx-http.conf 2 | map $http_origin $cors_origin_header { 3 | default ""; 4 | "~(^|^http:\/\/)(localhost$|localhost:[0-9]{1,4}$)" "$http_origin"; 5 | "~^http.?://.*\.extremesolution.com" "$http_origin"; 6 | } 7 | map $http_origin $cors_cred { 8 | default ""; 9 | "~(^|^http:\/\/)(localhost$|localhost:[0-9]{1,4}$)" "true"; 10 | "~^http.?://.*\.extremesolution.com" "true"; 11 | } 12 | -------------------------------------------------------------------------------- /dbsetup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | FILE=/app/.env 3 | 4 | if [ -f "$FILE" ]; then 5 | env $(cat .env) spdl migrate:latest --scope private --env $NODE_ENV 6 | 7 | if [ $NODE_ENV = "development" ]; then 8 | env $(cat .env) spdl seed:run --scope private --env $NODE_ENV 9 | fi 10 | 11 | else 12 | 13 | spdl migrate:latest --scope private --env $NODE_ENV 14 | if [ $NODE_ENV = "development" ]; then 15 | env $(cat .env) spdl seed:run --scope private --env $NODE_ENV 16 | fi 17 | 18 | fi 19 | exec "$@" -------------------------------------------------------------------------------- /app/auth/enforce/test.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | function validateToken(accessToken) { 4 | return new Promise((resolve, reject) => { 5 | jwt.verify( 6 | accessToken, 7 | process.env.JWT_SECRET, 8 | { 9 | algorithms: ['HS256'], 10 | }, 11 | (err, decoded) => { 12 | if (err) return reject(err); 13 | return resolve(decoded); 14 | }, 15 | ); 16 | }); 17 | } 18 | 19 | module.exports = { validateToken }; 20 | -------------------------------------------------------------------------------- /app/api/health/router.js: -------------------------------------------------------------------------------- 1 | const { router } = require('../../../app'); 2 | const controller = require('./controller'); 3 | 4 | router.get( 5 | '/health', 6 | router.wrapAsync(async (req, res) => await controller.health(req, res)), 7 | ); 8 | 9 | router.get( 10 | '/health/slow', 11 | router.wrapAsync(async (req, res) => await controller.healthSlow(req, res)), 12 | ); 13 | 14 | router.get( 15 | '/health/error', 16 | router.wrapAsync(async (req, res) => await controller.healthError(req, res)), 17 | ); 18 | -------------------------------------------------------------------------------- /app/api/accessCode/controller.js: -------------------------------------------------------------------------------- 1 | const { accessCodeService } = require('../../../app/lib/db'); 2 | 3 | /** 4 | * @method generate 5 | * 6 | * Generates a new access code for use with the Ingest/Upload service. 7 | * 8 | */ 9 | exports.generate = async (req, res) => { 10 | const code = await accessCodeService.create(); 11 | 12 | if (code == null || code.value == null) { 13 | res.status(500).json({ message: 'Internal Server Error' }); 14 | return; 15 | } 16 | 17 | res.status(201).json({ 18 | accessCode: code.value, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Execution 2 | * Edit the tests/FASTAPI.postman_environment.json and amend the base URL as appropriate 3 | * Run the following if you want an html report to be created under tests/tests/newman/ 4 | `docker run --mount type=bind,source="$(pwd)"/tests,target=/etc/newman --entrypoint /bin/sh postman/newman:ubuntu -c "npm i -g newman-reporter-html; newman run backend_collection.json -e FASTAPI.postman_environment.json -r html"`` 5 | * Run the following if you want an exit code: 6 | `docker run --mount type=bind,source="$(pwd)"/tests,target=/etc/newman postman/newman:ubuntu run backend_collection.json -e FASTAPI.postman_environment.json` 7 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const guard = require('./app/lib/auth'); 3 | 4 | const config = { 5 | port: process.env.EXPRESSPORT || '3000', 6 | bind: '127.0.0.1', 7 | appFolder: path.join(__dirname, 'app'), 8 | wrapAsync: (asyncFn, validate = false) => { 9 | return (req, res, next) => { 10 | if (validate) { 11 | return guard 12 | .handleReq(req, res, () => asyncFn(req, res, next)) 13 | .catch(next); 14 | } 15 | asyncFn(req, res, next).catch(next); 16 | }; 17 | }, 18 | }; 19 | 20 | const server = require('@pathcheck/safeplaces-server')(config); 21 | server.setupAndCreate(); 22 | 23 | module.exports = server; 24 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # node-waf configuration 20 | .lock-wscript 21 | 22 | # Compiled binary addons (http://nodejs.org/api/addons.html) 23 | build/Release 24 | 25 | # Dependency directory 26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 27 | node_modules 28 | 29 | server/*.spec.js 30 | kubernetes 31 | -------------------------------------------------------------------------------- /app/api/health/controller.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @method health 3 | * 4 | * Health Check 5 | * 6 | */ 7 | exports.health = async (req, res) => { 8 | const data = { 9 | message: 'All Ok!', 10 | }; 11 | 12 | res.status(200).json(data); 13 | }; 14 | 15 | /** 16 | * @method health 17 | * 18 | * Health Check 19 | * 20 | */ 21 | exports.healthSlow = async (req, res) => { 22 | const data = { 23 | message: 'All Slow!', 24 | }; 25 | 26 | setTimeout(function () { 27 | res.status(200).json(data); 28 | }, 2500); 29 | }; 30 | 31 | /** 32 | * @method healthError 33 | * 34 | * Health Error Check 35 | * 36 | */ 37 | exports.healthError = async () => { 38 | throw new Error('Problem here.'); 39 | }; 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13.13.0 AS build-env 2 | WORKDIR /app 3 | ENV NODE_ENV=development 4 | ADD . $WORKDIR 5 | RUN npm install 6 | 7 | FROM extremesolution/nodejs-nginx:node13.3.0-276a56e 8 | COPY --from=build-env /app /app 9 | WORKDIR /app 10 | ADD wait-for.sh /wait-for.sh 11 | ADD deployment-configs/nginx-app.conf /etc/nginx/conf.d/nginx-app.conf 12 | ADD deployment-configs/nginx-http.conf /etc/nginx/conf.d/nginx-http.conf 13 | ADD deployment-configs/supervisor.pm2.conf /etc/supervisor/conf.d/supervisor.pm2.conf 14 | RUN npm install -g knex 15 | RUN npm install -g @pathcheck/data-layer --unsafe-perm 16 | ENTRYPOINT ["/app/dbsetup.sh"] 17 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] 18 | #CMD ["npm", "start"] -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | safeplaces: 4 | build: 5 | context: . 6 | container_name: safeplaces 7 | #restart: unless-stopped 8 | env_file: .env 9 | ports: 10 | - "8080:8080" 11 | volumes: 12 | - .:/app 13 | - node_modules:/app/node_modules 14 | networks: 15 | - safeplaces 16 | depends_on: 17 | - db 18 | db: 19 | image: postgres:12.1 20 | container_name: database 21 | env_file: 22 | - .database.env 23 | volumes: 24 | - dbdata:/var/lib/postgresql/data/ 25 | ports: 26 | - "5432" 27 | networks: 28 | - safeplaces 29 | networks: 30 | safeplace: 31 | driver: bridge 32 | 33 | volumes: 34 | dbdata: 35 | node_modules: 36 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | -------------------------------------------------------------------------------- /test/integration/health.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | process.env.DATABASE_URL = 3 | process.env.DATABASE_URL || 'postgres://localhost/safeplaces_test'; 4 | 5 | const chai = require('chai'); 6 | const should = chai.should(); // eslint-disable-line 7 | const chaiHttp = require('chai-http'); 8 | 9 | const app = require('../../app'); 10 | const server = app.getTestingServer(); 11 | 12 | chai.use(chaiHttp); 13 | 14 | describe('GET /health', function () { 15 | it('should return 200 and all ok message', function (done) { 16 | chai 17 | .request(server) 18 | .get('/health') 19 | .end(function (err, res) { 20 | res.should.have.status(200); 21 | res.should.be.json; // jshint ignore:line 22 | res.body.should.have.property('message'); 23 | res.body.message.should.be.equal('All Ok!'); 24 | done(); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /app/auth/enforce/prod.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | const jwks = require('jwks-rsa'); 3 | 4 | const jwksClient = jwks({ 5 | strictSsl: true, 6 | jwksUri: `${process.env.AUTH0_BASE_URL}/.well-known/jwks.json`, 7 | }); 8 | 9 | function getSigningKey(header, callback) { 10 | jwksClient.getSigningKey(header.kid, (err, key) => { 11 | if (err) throw err; 12 | const signingKey = key.getPublicKey(); 13 | return callback(null, signingKey); 14 | }); 15 | } 16 | 17 | function validateToken(accessToken) { 18 | return new Promise((resolve, reject) => { 19 | jwt.verify( 20 | accessToken, 21 | getSigningKey, 22 | { 23 | audience: process.env.AUTH0_API_AUDIENCE, 24 | algorithms: ['RS256'], 25 | }, 26 | (err, decoded) => { 27 | if (err) return reject(err); 28 | return resolve(decoded); 29 | }, 30 | ); 31 | }); 32 | } 33 | 34 | module.exports = { validateToken }; 35 | -------------------------------------------------------------------------------- /app/api/case/router.js: -------------------------------------------------------------------------------- 1 | // app/api/case/router.js 2 | 3 | const { router } = require('../../../app'); 4 | const controller = require('./controller'); 5 | 6 | router.post( 7 | '/case/consent-to-publishing', 8 | router.wrapAsync( 9 | async (req, res) => await controller.consentToPublish(req, res), 10 | true, 11 | ), 12 | ); 13 | 14 | router.post( 15 | '/case/stage', 16 | router.wrapAsync( 17 | async (req, res) => await controller.setCaseToStaging(req, res), 18 | true, 19 | ), 20 | ); 21 | 22 | router.post( 23 | '/cases/publish', 24 | router.wrapAsync( 25 | async (req, res) => await controller.publishCases(req, res), 26 | true, 27 | ), 28 | ); 29 | 30 | router.post( 31 | '/case/delete', 32 | router.wrapAsync( 33 | async (req, res) => await controller.deleteCase(req, res), 34 | true, 35 | ), 36 | ); 37 | 38 | router.put( 39 | '/case', 40 | router.wrapAsync( 41 | async (req, res) => await controller.updateOrganizationCase(req, res), 42 | true, 43 | ), 44 | ); 45 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | JWT_SECRET="TVCH846KJdIyuB0s+vhXmoJa1YcVcDSsLjv+jTUDKJKzySdMvmIzelTjshPylKlcKpQDX2RUVc5sSuNpgVKIqA==" 3 | EXPRESSPORT=3000 4 | DB_HOST=database 5 | DB_PASS=safepaths 6 | DB_USER=postgres 7 | DB_NAME=spdev 8 | DB_HOST_PUB=database 9 | DB_PASS_PUB=safepaths 10 | DB_USER_PUB=postgres 11 | DB_NAME_PUB=spdev_public 12 | PUBLISH_STORAGE_TYPE=gcs 13 | GOOGLE_APPLICATION_CREDENTIALS="google_service_account.json" 14 | GOOGLE_CLOUD_PROJECT=something 15 | GCLOUD_STORAGE_BUCKET=somethingOrOther 16 | S3_BUCKET=bucketName 17 | S3_REGION=regionName 18 | S3_ACCESS_KEY=something 19 | S3_SECRET_KEY=somethingSecret 20 | BYPASS_SAME_SITE=false 21 | SERVICE_NAME="API" 22 | DISPLAY_ERROR_MESSAGE=1 23 | GCLOUD_STORAGE_PATH='' 24 | GOOGLE_SECRET= 25 | AUTH0_BASE_URL= 26 | AUTH0_CLIENT_ID= 27 | AUTH0_CLIENT_SECRET= 28 | AUTH0_API_AUDIENCE= 29 | AUTH0_CLAIM_NAMESPACE= 30 | AUTH0_REALM= 31 | AUTH0_MANAGEMENT_API_AUDIENCE= 32 | AUTH0_MANAGEMENT_CLIENT_ID= 33 | AUTH0_MANAGEMENT_CLIENT_SECRET= 34 | AUTH0_MANAGEMENT_ENABLED= 35 | DOMAIN= 36 | -------------------------------------------------------------------------------- /app/api/organization/router.js: -------------------------------------------------------------------------------- 1 | const { router } = require('../../../app'); 2 | const controller = require('./controller'); 3 | 4 | router.get( 5 | '/organization', 6 | router.wrapAsync( 7 | async (req, res) => await controller.fetchOrganizationById(req, res), 8 | true, 9 | ), 10 | ); 11 | router.get( 12 | '/organization/configuration', 13 | router.wrapAsync( 14 | async (req, res) => await controller.fetchOrganizationConfig(req, res), 15 | true, 16 | ), 17 | ); 18 | router.put( 19 | '/organization/configuration', 20 | router.wrapAsync( 21 | async (req, res) => await controller.updateOrganization(req, res), 22 | true, 23 | ), 24 | ); 25 | 26 | // Cases 27 | 28 | router.post( 29 | '/organization/case', 30 | router.wrapAsync( 31 | async (req, res) => await controller.createOrganizationCase(req, res), 32 | true, 33 | ), 34 | ); 35 | 36 | router.get( 37 | '/organization/cases', 38 | router.wrapAsync( 39 | async (req, res) => await controller.fetchOrganizationCases(req, res), 40 | true, 41 | ), 42 | ); 43 | -------------------------------------------------------------------------------- /base/env/test/deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: spp-be-hermes 5 | labels: 6 | app: spp-be-hermes 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: spp-be-hermes 12 | template: 13 | metadata: 14 | labels: 15 | app: spp-be-hermes 16 | spec: 17 | containers: 18 | - name: spp-be-hermes 19 | image: gcr.io/PROJECT_ID/IMAGE:TAG 20 | imagePullPolicy: IfNotPresent 21 | env: 22 | - name: EXPRESSPORT 23 | value: "3000" 24 | envFrom: 25 | - secretRef: 26 | name: hrmes-spl-be 27 | ports: 28 | - name: express 29 | containerPort: 8080 30 | protocol: TCP 31 | livenessProbe: 32 | httpGet: 33 | path: /health 34 | port: express 35 | initialDelaySeconds: 20 36 | periodSeconds: 3 37 | readinessProbe: 38 | httpGet: 39 | path: /health 40 | port: express 41 | initialDelaySeconds: 30 42 | periodSeconds: 2 43 | -------------------------------------------------------------------------------- /app/lib/random.js: -------------------------------------------------------------------------------- 1 | const { randomFill } = require('crypto'); 2 | const { promisify } = require('util'); 3 | 4 | const randomFillAsync = promisify(randomFill); 5 | 6 | /** 7 | * @class Random 8 | * 9 | * Generates random integers using cryptographically secure random bytes. 10 | * 11 | */ 12 | class Random { 13 | constructor(capacity) { 14 | if (capacity < 4 || capacity > 4096) { 15 | throw new Error('byte count must be between 4 and 4096'); 16 | } 17 | this._buffer = Buffer.allocUnsafe(capacity || 1024); 18 | this._pos = -1; 19 | } 20 | 21 | /** 22 | * Returns a random number between 0 and (2^(bytes * 8))-1. 23 | * Bytes may be between 1 and 4. 24 | * 25 | * @method next 26 | * @param {Number} bytes 27 | * @return {Number} 28 | */ 29 | async next(bytes) { 30 | bytes = bytes || 4; 31 | 32 | if (bytes < 1 || bytes > 4) { 33 | throw new Error('byte count must be between 1 and 4'); 34 | } 35 | 36 | let result = 0; 37 | 38 | if (this._pos < 0 || this._pos + bytes > this._buffer.length) { 39 | await randomFillAsync(this._buffer); 40 | this._pos = 0; 41 | } 42 | 43 | while (bytes-- > 0) { 44 | result = (result << 8) | this._buffer[this._pos++]; 45 | } 46 | 47 | return result >>> 0; 48 | } 49 | } 50 | 51 | module.exports = Random; 52 | -------------------------------------------------------------------------------- /app/lib/auth.js: -------------------------------------------------------------------------------- 1 | const auth = require('@pathcheck/safeplaces-auth'); 2 | const { userService } = require('./db'); 3 | const policy = require('./policy'); 4 | 5 | const guard = new auth.Guard({ 6 | jwksUri: `${process.env.AUTH0_BASE_URL}/.well-known/jwks.json`, 7 | getUser: idm_id => userService.findOne({ idm_id }), 8 | authorize: (decoded, req) => { 9 | const roles = decoded[`${namespace}/roles`]; 10 | if (!roles) throw new Error('No roles found in token'); 11 | 12 | const { path } = req.route; 13 | const role = roles[0]; 14 | 15 | // Check if request is allowed by the policy. 16 | const allowed = policy.authorize(role, req.method.toUpperCase(), path); 17 | if (!allowed) { 18 | throw new Error('Operation is not allowed'); 19 | } 20 | }, 21 | strategy: () => { 22 | return process.env.NODE_ENV === 'test' 23 | ? auth.strategies.symJWT({ 24 | privateKey: process.env.JWT_SECRET, 25 | algorithm: 'HS256', 26 | }) 27 | : auth.strategies.auth0({ 28 | apiAudience: process.env.AUTH0_API_AUDIENCE, 29 | jwksUri: `${process.env.AUTH0_BASE_URL}/.well-known/jwks.json`, 30 | }); 31 | }, 32 | verbose: process.env.AUTH_LOGGING === 'verbose', 33 | }); 34 | 35 | const namespace = process.env.AUTH0_CLAIM_NAMESPACE; 36 | 37 | module.exports = guard; 38 | -------------------------------------------------------------------------------- /.github/workflows/google-cloud-run.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Google, LLC. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Build & Deploy to Cloud Run 16 | 17 | on: 18 | push: 19 | branches: 20 | - dev 21 | 22 | env: 23 | PROJECT_ID: ${{ secrets.RUN_PROJECT }} 24 | 25 | jobs: 26 | setup-build-deploy: 27 | name: Setup, Build, and Deploy 28 | runs-on: ubuntu-latest 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v2 33 | 34 | # Setup gcloud CLI 35 | - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master 36 | with: 37 | version: '286.0.0' 38 | service_account_email: ${{ secrets.RUN_SA_EMAIL }} 39 | service_account_key: ${{ secrets.RUN_SA_KEY }} 40 | project_id: ${{ secrets.RUN_PROJECT }} 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /app/lib/userManagement.js: -------------------------------------------------------------------------------- 1 | const auth = require('@pathcheck/safeplaces-auth'); 2 | const { v4: uuidv4 } = require('uuid'); 3 | const { userService } = require('./db'); 4 | 5 | const mApi = auth.api.management({ 6 | jwtClaimNamespace: process.env.AUTH0_CLAIM_NAMESPACE, 7 | privateKey: process.env.JWT_SECRET, 8 | auth0: { 9 | baseUrl: process.env.AUTH0_BASE_URL, 10 | clientId: process.env.AUTH0_MANAGEMENT_CLIENT_ID, 11 | clientSecret: process.env.AUTH0_MANAGEMENT_CLIENT_SECRET, 12 | apiAudience: process.env.AUTH0_MANAGEMENT_API_AUDIENCE, 13 | realm: process.env.AUTH0_REALM, 14 | }, 15 | db: { 16 | dbToIdm: async id => { 17 | const user = await userService.findOne({ id }); 18 | if (!user) return null; 19 | return user.idm_id; 20 | }, 21 | idmToDb: async idm_id => { 22 | const user = await userService.findOne({ idm_id }); 23 | if (!user) return null; 24 | return user.id; 25 | }, 26 | createUser: async (email, idmId, orgId) => { 27 | const dbId = uuidv4(); 28 | await userService.create({ 29 | username: email, 30 | id: dbId, 31 | idm_id: idmId, 32 | organization_id: orgId, 33 | }); 34 | return dbId; 35 | }, 36 | deleteUser: idm_id => userService.deleteWhere({ idm_id }), 37 | }, 38 | }); 39 | 40 | module.exports = mApi; 41 | -------------------------------------------------------------------------------- /base/env/prod/deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: prod-spl-be 5 | labels: 6 | app: prod-spl-be 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: prod-spl-be 12 | template: 13 | metadata: 14 | labels: 15 | app: prod-spl-be 16 | spec: 17 | containers: 18 | - name: prod-spl-be 19 | volumeMounts: 20 | - mountPath: /app/keys 21 | name: gcs-svc 22 | image: gcr.io/PROJECT_ID/IMAGE:TAG 23 | imagePullPolicy: IfNotPresent 24 | env: 25 | - name: PORT 26 | value: "3000" 27 | envFrom: 28 | - secretRef: 29 | name: prod-spl-be 30 | ports: 31 | - name: express 32 | containerPort: 3000 33 | protocol: TCP 34 | livenessProbe: 35 | httpGet: 36 | path: /health 37 | port: express 38 | initialDelaySeconds: 20 39 | periodSeconds: 3 40 | readinessProbe: 41 | httpGet: 42 | path: /health 43 | port: express 44 | initialDelaySeconds: 30 45 | periodSeconds: 2 46 | volumes: 47 | - name: gcs-svc 48 | configMap: 49 | name: spl-be-svc 50 | 51 | -------------------------------------------------------------------------------- /base/deployment.yml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: spp-be-express 5 | labels: 6 | app: spp-be-express 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | app: spp-be-express 12 | template: 13 | metadata: 14 | labels: 15 | app: spp-be-express 16 | spec: 17 | containers: 18 | - name: spp-be-express 19 | volumeMounts: 20 | - mountPath: /app/keys 21 | name: gcs-svc 22 | image: gcr.io/PROJECT_ID/IMAGE:TAG 23 | imagePullPolicy: IfNotPresent 24 | env: 25 | - name: EXPRESSPORT 26 | value: "3000" 27 | envFrom: 28 | - secretRef: 29 | name: staging-spl-be 30 | ports: 31 | - name: express 32 | containerPort: 8080 33 | protocol: TCP 34 | livenessProbe: 35 | httpGet: 36 | path: /health 37 | port: express 38 | initialDelaySeconds: 20 39 | periodSeconds: 3 40 | readinessProbe: 41 | httpGet: 42 | path: /health 43 | port: express 44 | initialDelaySeconds: 30 45 | periodSeconds: 2 46 | volumes: 47 | - name: gcs-svc 48 | configMap: 49 | name: spl-be-svc 50 | -------------------------------------------------------------------------------- /test/unit/sampleTest.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const expect = require('chai').expect; 4 | const mockData = require('../lib/mockData'); 5 | 6 | const { caseService } = require('../../app/lib/db'); 7 | 8 | let currentCase; 9 | 10 | describe('Data Layer Test', () => { 11 | before(async () => { 12 | await mockData.clearMockData(); 13 | 14 | let orgParams = { 15 | name: 'My Example Organization', 16 | info_website_url: 'http://sample.com', 17 | }; 18 | const currentOrg = await mockData.mockOrganization(orgParams); 19 | 20 | let newUserParams = { 21 | username: 'myAwesomeUser', 22 | password: 'myAwesomePassword', 23 | email: 'myAwesomeUser@yomanbob.com', 24 | organization_id: currentOrg.id, 25 | }; 26 | await mockData.mockUser(newUserParams); 27 | 28 | let expires_at = new Date().getTime() - 86400 * 10 * 1000; 29 | 30 | const params = { 31 | state: 'unpublished', 32 | organization_id: currentOrg.id, 33 | external_id: 1, 34 | expires_at: new Date(expires_at), 35 | }; 36 | 37 | currentCase = await caseService.createCase(params); 38 | }); 39 | 40 | it('should find the case just created', async () => { 41 | const caseFromDatabase = await caseService.findOne({ 42 | id: currentCase.caseId, 43 | }); 44 | expect(currentCase.caseOd).to.be.equal(caseFromDatabase.caseId); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /app/api/auth/controller.js: -------------------------------------------------------------------------------- 1 | const auth = require('@pathcheck/safeplaces-auth'); 2 | const { userService } = require('../../lib/db'); 3 | 4 | const gApi = auth.api.guard({ 5 | jwtClaimNamespace: process.env.AUTH0_CLAIM_NAMESPACE, 6 | db: { 7 | idmToDb: async idm_id => { 8 | const user = await userService.findOne({ idm_id }); 9 | if (!user) return null; 10 | return user.id; 11 | }, 12 | }, 13 | auth0: { 14 | baseUrl: process.env.AUTH0_BASE_URL, 15 | clientId: process.env.AUTH0_CLIENT_ID, 16 | clientSecret: process.env.AUTH0_CLIENT_SECRET, 17 | apiAudience: process.env.AUTH0_API_AUDIENCE, 18 | realm: process.env.AUTH0_REALM, 19 | }, 20 | cookie: { 21 | secure: process.env.NODE_ENV !== 'development', 22 | sameSite: process.env.BYPASS_SAME_SITE !== 'true', 23 | domain: process.env.DOMAIN, 24 | }, 25 | }); 26 | 27 | const endpoints = { 28 | login: gApi.login, 29 | logout: gApi.logout, 30 | mfa: gApi.mfa, 31 | users: { 32 | // Dummy endpoint namespaced under `/auth/users` for easy testing of whether 33 | // a user is allowed to access `/auth/users/**/*` resources. 34 | reflect: (req, res) => res.status(204).end(), 35 | }, 36 | }; 37 | 38 | if (process.env.AUTH0_MANAGEMENT_ENABLED === 'true') { 39 | const userManagementEndpoints = require('../../lib/userManagement'); 40 | Object.assign(endpoints.users, userManagementEndpoints.users); 41 | } 42 | 43 | module.exports = endpoints; 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SafePlaces Backend Service 2 | 3 | The SafePlaces Backend Service is an internal service that is utilized by the SafePlaces Frontend to configure an organization and create, stage, and publish redacted location data of Covid-19 positive individuals for use by the PathCheck GPS mobile application. 4 | 5 | Follow the links below for more information on how to setup and configure the SafePlaces. 6 | 7 | - [SafePlaces Docs Home](https://github.com/Path-Check/safeplaces-docs/tree/master) 8 | - [Databases](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/databases) 9 | - [Setting Up SafePlaces Backend Service](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/setup#setting-up-safeplaces-backend-service) 10 | - [Environment Variables](https://github.com/Path-Check/safeplaces-docs/blob/master/safeplaces-backend-services/environment-variables/safeplaces-backend-service.md) 11 | - [Authentication](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/authentication) 12 | - [User Account & Organization Setup](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/accounts-configuration) 13 | - [Publishing Location Data](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/published-data) 14 | - [Security Recommendations](https://github.com/Path-Check/safeplaces-docs/tree/master/safeplaces-backend-services/security) 15 | -------------------------------------------------------------------------------- /app/lib/writePublishedFiles.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | /** 4 | * 5 | * This is simple logic that will save the published files that are needed 6 | * for the Mobile apps to download data from. You will want to create your own 7 | * logic here and save to a public location. Your organizations API Endpoint 8 | * should point to the directory that the cursor.json file is located in. 9 | * 10 | * @method writePublishedFiles 11 | * @param {Object} pages 12 | * @param {String} baseLocation 13 | * @return {Boolean} 14 | */ 15 | 16 | module.exports = async (pages, baseLocation) => { 17 | const mkdir = path => { 18 | return new Promise((resolve, reject) => { 19 | fs.mkdir(path, { recursive: true }, err => { 20 | if (err) reject(err); 21 | resolve(true); 22 | }); 23 | }); 24 | }; 25 | 26 | const saveFile = (file, contents) => { 27 | return new Promise((resolve, reject) => { 28 | fs.writeFile(file, contents, err => { 29 | if (err) reject(err); 30 | resolve(true); 31 | }); 32 | }); 33 | }; 34 | 35 | const results = await mkdir(`${baseLocation}`); 36 | if (results) { 37 | await saveFile(`${baseLocation}/cursor.json`, JSON.stringify(pages.cursor)); 38 | let page, filename; 39 | for (page of pages.files) { 40 | filename = page.page_name.split('/').pop(); 41 | await saveFile(`${baseLocation}/${filename}`, JSON.stringify(page)); 42 | } 43 | return true; 44 | } else { 45 | throw new Error('Could not create directory.'); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /cloudbuild.yaml: -------------------------------------------------------------------------------- 1 | # GCP only cloudbuild script to build container image and deploy to Cloud Run Serverless 2 | 3 | substitutions: 4 | _SERVICE: 5 | _REGION: 6 | _SECRET_NAME: 7 | _PORT: 8 | _PLATFORM: 9 | _CLUSTER_NAME: 10 | _CLUSTER_LOCATION: 11 | 12 | steps: 13 | - id: 'Pull env file from Secrets Manager' 14 | name: gcr.io/cloud-builders/gcloud 15 | entrypoint: 'bash' 16 | args: [ '-c', "gcloud secrets versions access latest --secret=${_SECRET_NAME} --format='get(payload.data)' | tr '_-' '/+' | base64 -d > .env" ] 17 | 18 | - id: 'Build Container Image' 19 | name: 'registry.hub.docker.com/library/docker:18' 20 | args: [ 21 | 'build', 22 | '--tag', 'gcr.io/${PROJECT_ID}/github.com/path-check/${_SERVICE}:$BRANCH_NAME-$SHORT_SHA', 23 | '.' ] 24 | 25 | - id: 'Publish Container' 26 | name: 'registry.hub.docker.com/library/docker:18' 27 | args: [ 28 | 'push', 29 | 'gcr.io/${PROJECT_ID}/github.com/path-check/${_SERVICE}:$BRANCH_NAME-$SHORT_SHA', 30 | ] 31 | 32 | # 33 | # Deploys a Cloud Run service. 34 | # 35 | 36 | - id: 'Deploy to Cloud Run' 37 | name: 'gcr.io/google.com/cloudsdktool/cloud-sdk:293.0.0-alpine' 38 | args: 39 | - 'bash' 40 | - '-eEuo' 41 | - 'pipefail' 42 | - '-c' 43 | - |- 44 | gcloud run deploy "${_SERVICE}-${BRANCH_NAME}" \ 45 | --quiet \ 46 | --project "${PROJECT_ID}" \ 47 | --platform "${_PLATFORM}" \ 48 | --namespace "${_NAMESPACE}" \ 49 | --port "${_PORT}" \ 50 | --cluster "${_CLUSTER_NAME}" \ 51 | --cluster-location "${_CLUSTER_LOCATION}" \ 52 | --image "gcr.io/${PROJECT_ID}/github.com/path-check/${_SERVICE}:$BRANCH_NAME-$SHORT_SHA" 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor config files 2 | .DS_Store 3 | .idea/ 4 | .vscode/ 5 | 6 | config.yaml 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | firebase-debug.log* 15 | 16 | # Firebase cache 17 | .firebase/ 18 | 19 | # Firebase config 20 | 21 | # Uncomment this if you'd like others to create their own Firebase project. 22 | # For a team working on the same Firebase project(s), it is recommended to leave 23 | # it commented so all members can deploy to the same project(s) in .firebaserc. 24 | # .firebaserc 25 | 26 | # Runtime data 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | 32 | # Directory for instrumented libs generated by jscoverage/JSCover 33 | lib-cov 34 | 35 | # Coverage directory used by tools like istanbul 36 | coverage 37 | 38 | # nyc test coverage 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 42 | .grunt 43 | 44 | # Bower dependency directory (https://bower.io/) 45 | bower_components 46 | 47 | # node-waf configuration 48 | .lock-wscript 49 | 50 | # Compiled binary addons (http://nodejs.org/api/addons.html) 51 | build/Release 52 | 53 | # Dependency directories 54 | node_modules/ 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env 74 | .envrc 75 | .git.env 76 | 77 | # remove newman results 78 | tests/tests/newman/*.html 79 | newman-results.json 80 | 81 | Makefile 82 | 83 | google_service_account.json 84 | .DS_Store 85 | -------------------------------------------------------------------------------- /app/auth/enforce/common.js: -------------------------------------------------------------------------------- 1 | const { userService } = require('../../../app/lib/db'); 2 | 3 | function sourceCookie(req) { 4 | if (!req.cookies) { 5 | throw new Error('No cookies found'); 6 | } 7 | const accessToken = req.cookies['auth_token'] || req.cookies['access_token']; 8 | if (!accessToken) { 9 | throw new Error('No access token found in cookie'); 10 | } 11 | return accessToken; 12 | } 13 | 14 | function sourceHeader(req) { 15 | if (!req.headers) { 16 | throw new Error('No headers found'); 17 | } 18 | const authHeader = 19 | req.headers['Authorization'] || req.headers['authorization']; 20 | if (!authHeader) { 21 | throw new Error('No authorization header found'); 22 | } 23 | const accessToken = authHeader.replace('Bearer ', '').trim(); 24 | if (!accessToken) { 25 | throw new Error('No access token found in header'); 26 | } 27 | return accessToken; 28 | } 29 | 30 | function sourceToken(req) { 31 | let accessToken; 32 | try { 33 | accessToken = sourceCookie(req); 34 | } catch (e) {} 35 | if (accessToken) return accessToken; 36 | try { 37 | accessToken = sourceHeader(req); 38 | } catch (e) {} 39 | return accessToken; 40 | } 41 | 42 | async function getUser(idm_id) { 43 | return await userService.findOne({ idm_id }); 44 | } 45 | 46 | async function verifyRequest(req, validateToken) { 47 | const accessToken = sourceToken(req); 48 | if (!accessToken) throw new Error('Access token not found'); 49 | 50 | const decoded = await validateToken(accessToken); 51 | const user = await getUser(decoded.sub); 52 | if (!user) throw new Error('No user found'); 53 | 54 | return user; 55 | } 56 | 57 | module.exports = { sourceToken, getUser, verifyRequest }; 58 | -------------------------------------------------------------------------------- /app/lib/writeToS3Bucket.js: -------------------------------------------------------------------------------- 1 | const S3 = require('aws-sdk/clients/s3'); 2 | 3 | /** 4 | * 5 | * Example of writing to an AWS S3 Bucket 6 | * 7 | * This is simple logic that will save the published files that are needed 8 | * for the Mobile apps to download data from. You will want to create your own 9 | * logic here and save to a public location. Your organizations API Endpoint 10 | * should point to the directory that the cursor.json file is located in. 11 | * 12 | * @method writePublishedFiles 13 | * @param {Object} pages 14 | * @return {Boolean} 15 | */ 16 | 17 | module.exports = async pages => { 18 | if (!process.env.S3_BUCKET) throw new Error('S3 bucket not set.'); 19 | if (!process.env.S3_REGION) throw new Error('S3 region not set.'); 20 | if (!process.env.S3_ACCESS_KEY) throw new Error('S3 access key not set.'); 21 | if (!process.env.S3_SECRET_KEY) throw new Error('S3 secret not set.'); 22 | 23 | const storage = new S3({ 24 | accessKeyId: process.env.S3_ACCESS_KEY, 25 | secretAccessKey: process.env.S3_SECRET_KEY, 26 | }); 27 | 28 | const saveFile = (filename, contents) => { 29 | const config = { 30 | Key: filename, 31 | Bucket: process.env.S3_BUCKET, 32 | Body: Buffer.from(contents), 33 | }; 34 | return new Promise((resolve, reject) => { 35 | storage.upload(config, function (err, data) { 36 | if (err != null) { 37 | reject(err); 38 | } else { 39 | resolve(data); 40 | } 41 | }); 42 | }); 43 | }; 44 | 45 | await saveFile(`cursor.json`, JSON.stringify(pages.cursor)); 46 | 47 | for (let page of pages.files) { 48 | const filename = page.page_name.split('/').pop(); 49 | await saveFile(filename, JSON.stringify(page)); 50 | } 51 | 52 | return true; 53 | }; 54 | -------------------------------------------------------------------------------- /wait-for.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # original script: https://github.com/eficode/wait-for/blob/master/wait-for 4 | 5 | TIMEOUT=15 6 | QUIET=0 7 | 8 | echoerr() { 9 | if [ "$QUIET" -ne 1 ]; then printf "%s\n" "$*" 1>&2; fi 10 | } 11 | 12 | usage() { 13 | exitcode="$1" 14 | cat << USAGE >&2 15 | Usage: 16 | $cmdname host:port [-t timeout] [-- command args] 17 | -q | --quiet Do not output any status messages 18 | -t TIMEOUT | --timeout=timeout Timeout in seconds, zero for no timeout 19 | -- COMMAND ARGS Execute command with args after the test finishes 20 | USAGE 21 | exit "$exitcode" 22 | } 23 | 24 | wait_for() { 25 | for i in `seq $TIMEOUT` ; do 26 | nc -z "$HOST" "$PORT" > /dev/null 2>&1 27 | 28 | result=$? 29 | if [ $result -eq 0 ] ; then 30 | if [ $# -gt 0 ] ; then 31 | exec "$@" 32 | fi 33 | exit 0 34 | fi 35 | sleep 1 36 | done 37 | echo "Operation timed out" >&2 38 | exit 1 39 | } 40 | 41 | while [ $# -gt 0 ] 42 | do 43 | case "$1" in 44 | *:* ) 45 | HOST=$(printf "%s\n" "$1"| cut -d : -f 1) 46 | PORT=$(printf "%s\n" "$1"| cut -d : -f 2) 47 | shift 1 48 | ;; 49 | -q | --quiet) 50 | QUIET=1 51 | shift 1 52 | ;; 53 | -t) 54 | TIMEOUT="$2" 55 | if [ "$TIMEOUT" = "" ]; then break; fi 56 | shift 2 57 | ;; 58 | --timeout=*) 59 | TIMEOUT="${1#*=}" 60 | shift 1 61 | ;; 62 | --) 63 | shift 64 | break 65 | ;; 66 | --help) 67 | usage 0 68 | ;; 69 | *) 70 | echoerr "Unknown argument: $1" 71 | usage 1 72 | ;; 73 | esac 74 | done 75 | 76 | if [ "$HOST" = "" -o "$PORT" = "" ]; then 77 | echoerr "Error: you need to provide a host and port to test." 78 | usage 2 79 | fi 80 | 81 | wait_for "$@" 82 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.0 - Tuesday, August 18, 2020 4 | 5 | ### Updates 6 | 7 | - Complete E2E user management and onboarding functionality 8 | - Updates to READMEs 9 | - Update cursor.json to include checksum values 10 | - Remove HASHING_TEST environment variables from env.template and disable in production environments 11 | - Return application version number in from `/organization/configuration` endpoints 12 | 13 | ### Breaking Changes 14 | 15 | In order to make use of user management functionality the `AUTH0_MANAGEMENT_ENABLED` environment variable must be set to `true` and the following environment variables must be set with your Auth0 management API credentials: 16 | 17 | ``` 18 | AUTH0_MANAGEMENT_API_AUDIENCE 19 | AUTH0_MANAGEMENT_CLIENT_ID 20 | AUTH0_MANAGEMENT_CLIENT_SECRET 21 | ``` 22 | 23 | ## v2.0.0 - Thursday, July 23, 2020 24 | 25 | ### Updates 26 | - Move case points related endpoints to new standalone [discreet to duration format translation service](https://github.com/Path-Check/safeplaces-backend-translation) 27 | - Add CSRF protection 28 | - Increased logging in service responsible for publishing 29 | - Small bug fixes and general cleanup of codebase 30 | 31 | ### Breaking Changes 32 | 33 | This release is not backwards compatible. A deployed instance of the [SafePlaces Translation Service](https://github.com/Path-Check/safeplaces-backend-translation) is required to maintain baseline application functionality. In order to insure a smooth deploy it is recommended that you deploy SafePlaces in the following order: 34 | 35 | 1. Configure and deploy the [SafePlaces Translation Service](https://github.com/Path-Check/safeplaces-backend-translation) 36 | 2. Deploy the most recent release (master branch) of the [SafePlaces Frontend](https://github.com/Path-Check/safeplaces-frontend) 37 | 3. Deploy this release of the SafePlaces Backend 38 | -------------------------------------------------------------------------------- /test/integration/accessCode.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | process.env.DATABASE_URL = 3 | process.env.DATABASE_URL || 'postgres://localhost/safeplaces_test'; 4 | 5 | const chai = require('chai'); 6 | const should = chai.should(); // eslint-disable-line 7 | const chaiHttp = require('chai-http'); 8 | 9 | const app = require('../../app'); 10 | const server = app.getTestingServer(); 11 | 12 | const mockData = require('../lib/mockData'); 13 | const mockAuth = require('../lib/mockAuth'); 14 | 15 | chai.use(chaiHttp); 16 | 17 | describe('POST /access-code', () => { 18 | let token; 19 | 20 | before(async () => { 21 | await mockData.clearMockData(); 22 | 23 | const orgParams = { 24 | name: 'Test Organization', 25 | info_website_url: 'http://test.com', 26 | }; 27 | 28 | const org = await mockData.mockOrganization(orgParams); 29 | 30 | const userParams = { 31 | username: 'test', 32 | organization_id: org.id, 33 | }; 34 | 35 | const user = await mockData.mockUser(userParams); 36 | token = mockAuth.getAccessToken(user.idm_id, 'admin'); 37 | 38 | await mockData.mockAccessCode(); 39 | }); 40 | 41 | it('should fail for unauthorized clients', async () => { 42 | let result = await chai 43 | .request(server) 44 | .post('/access-code') 45 | .set('X-Requested-With', 'XMLHttpRequest') 46 | .send(); 47 | result.should.have.status(403); 48 | }); 49 | 50 | it('should create a new access code', async () => { 51 | let result = await chai 52 | .request(server) 53 | .post('/access-code') 54 | .set('Cookie', `access_token=${token}`) 55 | .set('X-Requested-With', 'XMLHttpRequest') 56 | .send(); 57 | result.should.have.status(201); 58 | 59 | let accessCode = result.body.accessCode; 60 | chai.should().exist(accessCode); 61 | accessCode.length.should.equal(6); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /app/lib/policy.js: -------------------------------------------------------------------------------- 1 | const xp = require('xpolicy'); 2 | const { NotIn, NotEq, Eq, Not, StartsWith, Any } = xp.rules; 3 | 4 | const enforcer = new xp.Enforcer(); 5 | 6 | enforcer.addPolicy( 7 | new xp.Policy({ 8 | id: 1, 9 | description: `Allow a contact tracer to do anything to any resource except 10 | case publishing, organization configuration, and user management.`, 11 | subject: Eq('contact_tracer'), 12 | resource: NotIn([ 13 | '/cases/publish', 14 | '/organization/configuration', 15 | StartsWith('/auth/users'), 16 | ]), 17 | action: { 18 | method: Any(), 19 | }, 20 | effect: xp.effects.Allow, 21 | }), 22 | ); 23 | 24 | enforcer.addPolicy( 25 | new xp.Policy({ 26 | id: 2, 27 | description: `Allow a contact tracer to do anything except PUT to the 28 | organization configuration.`, 29 | subject: Eq('contact_tracer'), 30 | resource: Eq('/organization/configuration'), 31 | action: { 32 | method: NotEq('PUT'), 33 | }, 34 | effect: xp.effects.Allow, 35 | }), 36 | ); 37 | 38 | enforcer.addPolicy( 39 | new xp.Policy({ 40 | id: 3, 41 | description: `Allow an admin to do anything to any resource except for 42 | user management.`, 43 | subject: Eq('admin'), 44 | resource: Not(StartsWith('/auth/users')), 45 | action: { 46 | method: Any(), 47 | }, 48 | effect: xp.effects.Allow, 49 | }), 50 | ); 51 | 52 | enforcer.addPolicy( 53 | new xp.Policy({ 54 | id: 4, 55 | description: `Allow a super admin to do anything to any resource.`, 56 | subject: Eq('super_admin'), 57 | resource: Any(), 58 | action: { 59 | method: Any(), 60 | }, 61 | effect: xp.effects.Allow, 62 | }), 63 | ); 64 | 65 | module.exports = { 66 | authorize: (role, method, path) => { 67 | const op = new xp.Operation({ 68 | subject: role, 69 | resource: path, 70 | action: { 71 | method, 72 | }, 73 | }); 74 | return enforcer.isAllowed(op); 75 | }, 76 | }; 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "safe-places-server", 3 | "version": "2.1.0-alpha", 4 | "private": true, 5 | "scripts": { 6 | "migrate:up": "spdl migrate:latest --scope private --env development", 7 | "migrate:down": "spdl migrate:rollback --scope private --env development", 8 | "seed:dev": "spdl seed:run --scope private --env development", 9 | "seed:test": "spdl migrate:latest --scope private --env test", 10 | "start": "node ./bin/www", 11 | "lint": "eslint ./", 12 | "lint:fix": "eslint ./ --fix", 13 | "pretest": "npm run migrate:up && npm run seed:test", 14 | "test": "nyc --reporter=html --reporter=text mocha test/**/*.* --exit", 15 | "posttest": "npm run seed:dev", 16 | "test:int": "mocha test/integration/*.* --exit", 17 | "test:unit": "mocha test/unit/*.* --exit", 18 | "test:all": "mocha test/**/*.* --exit", 19 | "format": "prettier --write \"**/*.{js,json}\"" 20 | }, 21 | "pre-commit": [ 22 | "format", 23 | "lint", 24 | "test" 25 | ], 26 | "dependencies": { 27 | "@google-cloud/secret-manager": "^3.1.0", 28 | "@google-cloud/storage": "^5.0.1", 29 | "@pathcheck/data-layer": "^1.0.5", 30 | "@pathcheck/safeplaces-auth": "^2.1.1", 31 | "@pathcheck/safeplaces-server": "^0.0.14", 32 | "adm-zip": "^0.4.14", 33 | "ajv": "^6.12.2", 34 | "atob": "^2.1.2", 35 | "aws-sdk": "^2.728.0", 36 | "bcrypt": "^5.0.0", 37 | "bluebird": "^3.7.2", 38 | "chalk": "^4.1.0", 39 | "crypto": "^1.0.1", 40 | "debug": "~2.6.9", 41 | "dotenv": "8.2.0", 42 | "google-auth-library": "^6.0.6", 43 | "jsonwebtoken": "^8.5.1", 44 | "moment": "~2.19.3", 45 | "ngeohash": "^0.6.3", 46 | "uuid": "^8.3.0", 47 | "xpolicy": "^0.3.1" 48 | }, 49 | "devDependencies": { 50 | "chai": "^4.2.0", 51 | "chai-http": "^4.3.0", 52 | "eslint": "^7.6.0", 53 | "jshint": "^2.12.0", 54 | "mocha": "^7.1.1", 55 | "nyc": "^15.0.1", 56 | "pre-commit": "^1.2.2", 57 | "prettier": "^2.0.5", 58 | "random-coordinates": "^1.0.1", 59 | "sinon": "^9.0.2" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/tests/FASTAPI.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "025b7c4c-7179-4588-af5c-d352434ad8bd", 3 | "name": "FASTAPI", 4 | "values": [ 5 | { 6 | "key": "baseUrl", 7 | "value": "https://api.demo.safeplaces.extremesolution.com", 8 | "enabled": true 9 | }, 10 | { 11 | "key": "IngestServiceAPI", 12 | "value": "https://ingest.demo.safeplaces.extremesolution.com", 13 | "enabled": true 14 | }, 15 | { 16 | "key": "backEndbaseUrl", 17 | "value": "https://zeus.safeplaces.extremesolution.com", 18 | "enabled": true 19 | }, 20 | { 21 | "key": "customerID", 22 | "value": "1416867", 23 | "enabled": true 24 | }, 25 | { 26 | "key": "serialNumber", 27 | "value": "xgm45124", 28 | "enabled": true 29 | }, 30 | { 31 | "key": "startDate", 32 | "value": "2018-03-01 00:00:00", 33 | "enabled": true 34 | }, 35 | { 36 | "key": "endDate", 37 | "value": "2018-03-10 00:00:00", 38 | "enabled": true 39 | }, 40 | { 41 | "key": "env", 42 | "value": "prod", 43 | "enabled": true 44 | }, 45 | { 46 | "key": "token", 47 | "value": "", 48 | "enabled": true 49 | }, 50 | { 51 | "key": "organizationId", 52 | "value": "", 53 | "enabled": true 54 | }, 55 | { 56 | "key": "UserId", 57 | "value": "", 58 | "enabled": true 59 | }, 60 | { 61 | "key": "contactId", 62 | "value": "", 63 | "enabled": true 64 | }, 65 | { 66 | "key": "accessCode", 67 | "value": "", 68 | "enabled": true 69 | }, 70 | { 71 | "key": "orgID", 72 | "value": "", 73 | "enabled": true 74 | }, 75 | { 76 | "key": "extID", 77 | "value": "", 78 | "enabled": true 79 | }, 80 | { 81 | "key": "uploadId", 82 | "value": "", 83 | "enabled": true 84 | } 85 | ], 86 | "_postman_variable_scope": "environment", 87 | "_postman_exported_at": "2020-06-30T15:53:13.937Z", 88 | "_postman_exported_using": "Postman/7.26.1" 89 | } 90 | -------------------------------------------------------------------------------- /app/auth/utils.js: -------------------------------------------------------------------------------- 1 | const crypto = require('crypto'); 2 | const jwt = require('jsonwebtoken'); 3 | 4 | function isValidDate(date) { 5 | return ( 6 | date && 7 | Object.prototype.toString.call(date) === '[object Date]' && 8 | !isNaN(date) 9 | ); 10 | } 11 | 12 | function signJWT({ subject, role, expires }) { 13 | if (!isValidDate(expires)) { 14 | throw new Error('Expires should be a date'); 15 | } 16 | return jwt.sign( 17 | { 18 | sub: subject, 19 | role: role, 20 | iat: Math.floor(Date.now() / 1000), // Get time in seconds 21 | exp: Math.floor(expires.getTime() / 1000), // Get time in seconds 22 | }, 23 | process.env.JWT_SECRET, 24 | { 25 | algorithm: 'HS256', 26 | }, 27 | ); 28 | } 29 | 30 | function generateCSRFToken() { 31 | return crypto 32 | .randomBytes(32) 33 | .toString('base64') 34 | .replace(/[^a-zA-Z0-9]/g, '') 35 | .substr(0, 16); 36 | } 37 | 38 | function generateQueryString(obj) { 39 | let queryString = ''; 40 | for (const [k, v] of Object.entries(obj)) { 41 | const encodedV = encodeURIComponent(String(v)); 42 | queryString += `&${k}=${encodedV}`; 43 | } 44 | queryString = queryString.substr(1); 45 | return queryString; 46 | } 47 | 48 | function generateCookieString(attributes) { 49 | const { name, value, expires, httpOnly, sameSite, path, secure } = attributes; 50 | 51 | if (!isValidDate(expires)) { 52 | throw new Error('Expires should be a date'); 53 | } 54 | 55 | let cookieString = `${name}=${value};`; 56 | if (expires) { 57 | cookieString += `Expires=${expires.toUTCString()};`; 58 | } 59 | if (path) { 60 | cookieString += `Path=${path};`; 61 | } 62 | if (httpOnly) { 63 | cookieString += 'HttpOnly;'; 64 | } 65 | if (secure) { 66 | cookieString += 'Secure;'; 67 | } 68 | if (sameSite) { 69 | cookieString += 'SameSite=Strict;'; 70 | } else { 71 | cookieString += 'SameSite=None;'; 72 | } 73 | 74 | return cookieString; 75 | } 76 | 77 | module.exports = { 78 | generateCookieString, 79 | generateQueryString, 80 | generateCSRFToken, 81 | signJWT, 82 | }; 83 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **Table of Contents** 2 | 3 | - [How to Contribute](#how-to-contribute) 4 | - [Active Contributor Agreement](#active-contributor-agreement) 5 | - [Git Flow](#git-flow) 6 | - [Code reviews](#code-reviews) 7 | - [Get involved](#get-involved) 8 | 9 | # How to Contribute 10 | 11 | We'd love to accept your patches and contributions to this project. There are 12 | just a few small guidelines you need to follow. 13 | 14 | ## Active Contributor Agreement 15 | 16 | Contributions to this project must be accompanied by a Active Contributor Agreement. 17 | This is an agreement between Safe Paths Active Contributing Members (ACM) and Path Check, Inc. (PCI) 18 | By completing the attached Google Form at the bottom of [this agreement](https://docs.google.com/document/d/1sExarTjhd3vV1GWWA32BAEhpS_Rl1n-LD0qPURAeSoU), you will affirm this agreement. 19 | 20 | ## Git Flow 21 | 22 | We follow [git flow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) for accepting contributions to this project. 23 | 24 | ### Development Branches 25 | 26 | #### dev-mvp 27 | 28 | This is the development branch for our Minimum Viable Product ([API Spec for the MVP is here](https://github.com/Path-Check/safeplaces-frontend/blob/master/Safe-Places-Server.md)). Once this release is frozen, we will move the development to a single development branch. 29 | 30 | #### dev-react 31 | 32 | This is the development branch for our subsequent versions of Safe Places which uses a react js based frontend. 33 | 34 | #### master 35 | 36 | Master has the code for latest release of SafePlaces application. 37 | 38 | ## Code reviews 39 | 40 | All submissions, including submissions by project members, require review. We 41 | use GitHub pull requests for this purpose. Consult 42 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 43 | information on using pull requests. 44 | 45 | ## Get involved 46 | 47 | * [Slack](https://covidsafepaths.slack.com) 48 | * [Twitter](https://twitter.com/covidsafepaths) 49 | * [Mailing List - Frontend](safe-places-fe@googlegroups.com) 50 | * [Mailing List - Backend](safe-places-be@googlegroups.com) -------------------------------------------------------------------------------- /app/api/auth/router.js: -------------------------------------------------------------------------------- 1 | const { router } = require('../../../app'); 2 | const controller = require('./controller'); 3 | 4 | /** 5 | * Authentication API 6 | */ 7 | router.post('/auth/login', controller.login); 8 | router.get('/auth/logout', controller.logout); 9 | 10 | /** 11 | * Multi-factory Authentication API 12 | */ 13 | router.post('/auth/mfa/enroll', controller.mfa.enroll); 14 | router.post('/auth/mfa/challenge', controller.mfa.challenge); 15 | router.post('/auth/mfa/verify', controller.mfa.verify); 16 | router.post('/auth/mfa/recover', controller.mfa.recover); 17 | 18 | router.get( 19 | '/auth/users/reflect', 20 | router.wrapAsync( 21 | async (req, res, next) => await controller.users.reflect(req, res, next), 22 | true, 23 | ), 24 | ); 25 | 26 | if (process.env.AUTH0_MANAGEMENT_ENABLED === 'true') { 27 | router.post('/auth/register', controller.users.register); 28 | router.post('/auth/reset-password', controller.users.resetPassword); 29 | 30 | /** 31 | * User Management API 32 | */ 33 | router.post( 34 | '/auth/users/list', 35 | router.wrapAsync( 36 | async (req, res, next) => await controller.users.list(req, res, next), 37 | true, 38 | ), 39 | ); 40 | router.post( 41 | '/auth/users/get', 42 | router.wrapAsync( 43 | async (req, res, next) => await controller.users.get(req, res, next), 44 | true, 45 | ), 46 | ); 47 | router.post( 48 | '/auth/users/create', 49 | router.wrapAsync( 50 | async (req, res, next) => await controller.users.create(req, res, next), 51 | true, 52 | ), 53 | ); 54 | router.post( 55 | '/auth/users/delete', 56 | router.wrapAsync( 57 | async (req, res, next) => await controller.users.delete(req, res, next), 58 | true, 59 | ), 60 | ); 61 | router.post( 62 | '/auth/users/assign-role', 63 | router.wrapAsync( 64 | async (req, res, next) => 65 | await controller.users.assignRole(req, res, next), 66 | true, 67 | ), 68 | ); 69 | router.post( 70 | '/auth/users/reset-mfa', 71 | router.wrapAsync( 72 | async (req, res, next) => await controller.users.resetMfa(req, res, next), 73 | true, 74 | ), 75 | ); 76 | router.post( 77 | '/auth/users/reset-password', 78 | router.wrapAsync( 79 | async (req, res, next) => 80 | await controller.users.createPasswordResetTicket(req, res, next), 81 | true, 82 | ), 83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /tools/validateEnv.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Ajv = require('ajv'); 4 | const chalk = require('chalk'); 5 | 6 | // Instantiate an ajv object. 7 | const ajv = new Ajv({ 8 | coerceTypes: true, // Enable type coercion. 9 | allErrors: true, // Output all validation errors. 10 | }); 11 | 12 | // Read and parse the JSON schema. 13 | const schema = JSON.parse( 14 | fs 15 | .readFileSync(path.resolve(__dirname, '../env.schema.json')) 16 | .toString('utf-8'), 17 | ); 18 | 19 | // Compile the schema and obtain the validation function. 20 | const validate = ajv.compile(schema); 21 | 22 | /** 23 | * Validates the environment variables. 24 | * 25 | * @param env The environment variables object 26 | * @returns an array [valid, errors], where valid is a boolean and errors is 27 | * an array of validation errors, if any 28 | */ 29 | function check(env) { 30 | const valid = validate(env); 31 | return [valid, validate.errors]; 32 | } 33 | 34 | /** 35 | * Logs the errors to console in a fancy format. 36 | * 37 | * @param errors The array of validation errors 38 | */ 39 | function print(errors) { 40 | // Convert the array of raw errors to text format. 41 | const text = ajv.errorsText(errors, { dataVar: 'env', separator: ';' }); 42 | 43 | // Convert the error text to an array of formatted errors. 44 | const textArr = text.split(';'); 45 | 46 | for (let report of textArr) { 47 | // Remove the `env.` prefix from the error message. 48 | report = report.replace('env.', 'env '); 49 | console.error(`[ ${chalk.redBright('✗')} ] ${report}`); 50 | } 51 | 52 | console.error(''); 53 | } 54 | 55 | /** 56 | * Validates the environment variables and logs the process to console 57 | * in a fancy format. 58 | * 59 | * @param env The environment variables object to validate 60 | */ 61 | function prettyCheck(env) { 62 | console.log( 63 | `\n[ ${chalk.blueBright('?')} ] Validating environment variables...`, 64 | ); 65 | 66 | // Validate the environment variables. 67 | const [valid, errors] = check(env); 68 | 69 | // If there is a validation error, log the errors messages in a pretty format. 70 | // Else, log that the validation has completed successfully. 71 | if (!valid) { 72 | print(errors); 73 | } else { 74 | console.log( 75 | `[ ${chalk.greenBright('✓')} ] All environment variables valid\n`, 76 | ); 77 | } 78 | } 79 | 80 | module.exports = prettyCheck; 81 | -------------------------------------------------------------------------------- /deployment-configs/nginx-app.conf: -------------------------------------------------------------------------------- 1 | location / { 2 | # try to serve files directly, fallback to the front controller 3 | proxy_pass http://127.0.0.1:3000 ; 4 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 5 | proxy_set_header X-Forwarded-Proto $scheme; 6 | proxy_set_header X-Forwarded-Port $server_port; 7 | 8 | # add_header Access-Control-Allow-Origin $http_origin always; 9 | # add_header 'Access-Control-Allow-Credentials' $cors_cred always; 10 | # add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD, PATCH, PUT, DELETE"; 11 | # add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; 12 | 13 | #if ($request_method = 'OPTIONS' ) { 14 | #return 204 no-content; 15 | # } 16 | 17 | 18 | set $cors 'true'; 19 | if ($http_origin ~ '^https?://(localhost|.*\.extremesolution\.com|.*\.safeplaces\.cloud)') { 20 | set $cors 'true'; 21 | } 22 | 23 | # always is required to add headers even if response's status is 4xx or 5xx 24 | if ($cors = 'true') { 25 | add_header 'Access-Control-Allow-Origin' "$http_origin" always; 26 | add_header 'Access-Control-Allow-Credentials' 'true' always; 27 | add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, HEAD, PATCH, PUT, DELETE' always; 28 | #add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With' always; 29 | add_header "Access-Control-Allow-Headers" "Authorization, Origin, X-Requested-With, Content-Type, Accept"; 30 | # required to be able to read Authorization header in frontend 31 | add_header 'Access-Control-Expose-Headers' 'Authorization' always; 32 | add_header 'Cache-Control' 'no-cache'; 33 | } 34 | 35 | # 2 if are required, nginx treats each if block as a different context 36 | if ($request_method = 'OPTIONS') { 37 | add_header 'Access-Control-Allow-Origin' "$http_origin"; 38 | add_header 'Access-Control-Allow-Credentials' 'true'; 39 | add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; 40 | add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With'; 41 | 42 | return 204; 43 | } 44 | 45 | 46 | } 47 | -------------------------------------------------------------------------------- /env.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-07/schema#", 3 | "title": "Environment Variables", 4 | "description": "The environment variables schema for the Safe Places server", 5 | "type": "object", 6 | "properties": { 7 | "JWT_SECRET": { 8 | "type": "string" 9 | }, 10 | "DB_HOST": { 11 | "type": "string" 12 | }, 13 | "DB_PASS": { 14 | "type": "string" 15 | }, 16 | "DB_USER": { 17 | "type": "string" 18 | }, 19 | "DB_NAME": { 20 | "type": "string" 21 | }, 22 | "DB_HOST_PUB": { 23 | "type": "string" 24 | }, 25 | "DB_PASS_PUB": { 26 | "type": "string" 27 | }, 28 | "DB_USER_PUB": { 29 | "type": "string" 30 | }, 31 | "DB_NAME_PUB": { 32 | "type": "string" 33 | }, 34 | "PUBLISH_STORAGE_TYPE": { 35 | "type": "string", 36 | "enum": ["gcs", "aws", "local"] 37 | }, 38 | "GOOGLE_APPLICATION_CREDENTIALS": { 39 | "type": "string" 40 | }, 41 | "GOOGLE_CLOUD_PROJECT": { 42 | "type": "string" 43 | }, 44 | "GCLOUD_STORAGE_BUCKET": { 45 | "type": "string" 46 | }, 47 | "S3_BUCKET": { 48 | "type": "string" 49 | }, 50 | "S3_REGION": { 51 | "type": "string" 52 | }, 53 | "S3_ACCESS_KEY": { 54 | "type": "string" 55 | }, 56 | "S3_SECRET_KEY": { 57 | "type": "string" 58 | }, 59 | "HASHING_TEST": { 60 | "type": "integer" 61 | }, 62 | "DISPLAY_ERROR_MESSAGE": { 63 | "type": "integer" 64 | }, 65 | "GCLOUD_STORAGE_PATH": { 66 | "type": "string" 67 | }, 68 | "GOOGLE_SECRET": { 69 | "type": "string" 70 | }, 71 | "DOMAIN": { 72 | "type": "string", 73 | "format": "hostname" 74 | }, 75 | "AUTH0_BASE_URL": { 76 | "type": "string", 77 | "format": "url" 78 | }, 79 | "AUTH0_CLIENT_ID": { 80 | "type": "string" 81 | }, 82 | "AUTH0_CLIENT_SECRET": { 83 | "type": "string" 84 | }, 85 | "AUTH0_API_AUDIENCE": { 86 | "type": "string", 87 | "format": "url" 88 | }, 89 | "AUTH0_CLAIM_NAMESPACE": { 90 | "type": "string", 91 | "format": "url" 92 | }, 93 | "AUTH0_REALM": { 94 | "type": "string" 95 | } 96 | }, 97 | "required": [ 98 | "JWT_SECRET", 99 | "EXPRESSPORT", 100 | "DB_HOST", 101 | "DB_PASS", 102 | "DB_USER", 103 | "DB_NAME", 104 | "DB_HOST_PUB", 105 | "DB_PASS_PUB", 106 | "DB_USER_PUB", 107 | "DB_NAME_PUB", 108 | "PUBLISH_STORAGE_TYPE" 109 | ] 110 | } 111 | -------------------------------------------------------------------------------- /test/integration/users.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | 3 | const chai = require('chai'); 4 | const chaiHttp = require('chai-http'); 5 | const expect = chai.expect; 6 | 7 | chai.use(chaiHttp); 8 | 9 | const app = require('../../app'); 10 | const server = app.getTestingServer(); 11 | 12 | const mockData = require('../lib/mockData'); 13 | const mockAuth = require('../lib/mockAuth'); 14 | 15 | describe('User management', () => { 16 | let ctToken = null; 17 | let adminToken = null; 18 | let saToken = null; 19 | let currentOrg = null; 20 | 21 | before(async () => { 22 | let orgParams = { 23 | name: 'My Example Organization', 24 | info_website_url: 'http://sample.com', 25 | notification_threshold_percent: 66, 26 | notification_threshold_timeframe: 30, 27 | days_to_retain_records: 14, 28 | region_coordinates: { 29 | ne: { latitude: 20.312764055951195, longitude: -70.45445121262883 }, 30 | sw: { latitude: 17.766025040122642, longitude: -75.49442923997258 }, 31 | }, 32 | api_endpoint_url: 'http://api.sample.com', 33 | reference_website_url: 'http://reference.sample.com', 34 | privacy_policy_url: 'http://privacy.reference.sample.com', 35 | completed_onboarding: true, 36 | }; 37 | currentOrg = await mockData.mockOrganization(orgParams); 38 | 39 | let newUserParams = { 40 | username: 'myAwesomeUser', 41 | organization_id: currentOrg.id, 42 | }; 43 | const user = await mockData.mockUser(newUserParams); 44 | ctToken = mockAuth.getAccessToken(user.idm_id, 'contact_tracer'); 45 | adminToken = mockAuth.getAccessToken(user.idm_id, 'admin'); 46 | saToken = mockAuth.getAccessToken(user.idm_id, 'super_admin'); 47 | }); 48 | 49 | it('rejects contact tracers', async () => { 50 | const results = await chai 51 | .request(server) 52 | .get('/auth/users/reflect') 53 | .set('Cookie', `access_token=${ctToken}`) 54 | .set('X-Requested-With', 'XMLHttpRequest') 55 | .set('content-type', 'application/json'); 56 | 57 | expect(results.statusCode).eq(403); 58 | expect(results.text).eq('Forbidden'); 59 | }); 60 | 61 | it('rejects admins', async () => { 62 | const results = await chai 63 | .request(server) 64 | .get('/auth/users/reflect') 65 | .set('Cookie', `access_token=${adminToken}`) 66 | .set('X-Requested-With', 'XMLHttpRequest') 67 | .set('content-type', 'application/json'); 68 | 69 | expect(results.statusCode).eq(403); 70 | expect(results.text).eq('Forbidden'); 71 | }); 72 | 73 | it('allows super admins', async () => { 74 | const results = await chai 75 | .request(server) 76 | .get('/auth/users/reflect') 77 | .set('Cookie', `access_token=${saToken}`) 78 | .set('X-Requested-With', 'XMLHttpRequest') 79 | .set('content-type', 'application/json'); 80 | 81 | expect(results.statusCode).eq(204); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will build a docker container, publish it to Google Container Registry, and deploy it to GKE when a release is created 2 | # 3 | # To configure this workflow: 4 | # 5 | # 1. Ensure that your repository contains the necessary configuration for your Google Kubernetes Engine cluster, including deployment.yml, kustomization.yml, service.yml, etc. 6 | # 7 | # 2. Set up secrets in your workspace: GKE_PROJECT with the name of the project, GKE_EMAIL with the service account email, GKE_KEY with the Base64 encoded JSON service account key (https://github.com/GoogleCloudPlatform/github-actions/tree/docs/service-account-key/setup-gcloud#inputs). 8 | # 9 | # 3. Change the values for the GKE_ZONE, GKE_CLUSTER, IMAGE, REGISTRY_HOSTNAME and DEPLOYMENT_NAME environment variables (below). 10 | 11 | name: Integration Tests 12 | 13 | on: 14 | pull_request: 15 | branches: 16 | - dev 17 | # paths: 18 | # - 'expressjs/**/**' 19 | push: 20 | branches: 21 | - dev-add-tests 22 | - PLACES-338 23 | # paths: 24 | # - 'expressjs/**/**' 25 | 26 | # Environment variables available to all jobs and steps in this workflow 27 | env: 28 | DB_HOST: localhost 29 | DB_NAME: spl 30 | DB_PASS: postgres 31 | DB_USER: postgres 32 | NODE_ENV: test 33 | JWT_EXP: 3600 34 | DB_HOST_PUB: localhost 35 | DB_USER_PUB: postgres 36 | DB_NAME_PUB: spl 37 | DB_PASS_PUB: postgres 38 | JWT_SECRET: ${{ secrets.JWT_SECRET }} 39 | SEED_MAPS_API_KEY: ${{ secrets.SEED_MAPS_API_KEY }} 40 | TESTS_ENV: ${{ secrets.TESTS_ENV }} 41 | AUTH_LOGOUT_REDIRECT_URL: 'http://127.0.0.1:5000' 42 | AUTH0_BASE_URL: 'https://pathcheck.us.auth0.com' 43 | AUTH0_CLIENT_ID: 'Dgy5n22Gcuo3zRHzWLk19CCZ9bUNbVhU' 44 | AUTH0_CLIENT_SECRET: 'fffp66sf-Qppw9xsC1pzUREzkdNGTz-8H8DdesL0sgGO74vivJs_NyhcDLQZjtJb' 45 | AUTH0_API_AUDIENCE: 'https://safeplaces.cloud/' 46 | AUTH0_CLAIM_NAMESPACE: 'https://safeplaces.cloud' 47 | AUTH0_MANAGEMENT_API_AUDIENCE: '' 48 | AUTH0_MANAGEMENT_CLIENT_ID: '' 49 | AUTH0_MANAGEMENT_CLIENT_SECRET: '' 50 | AUTH0_REALM: test 51 | 52 | jobs: 53 | run-integration-tests: 54 | name: Running Test Integrations 55 | runs-on: ubuntu-latest 56 | 57 | # Service containers to run with `container-job` 58 | services: 59 | # Label used to access the service container 60 | postgres: 61 | # Docker Hub image 62 | image: bitnami/postgresql:latest 63 | # Provide the password for postgres 64 | env: 65 | POSTGRES_PASSWORD: postgres 66 | POSTGRESQL_DATABASE: spl 67 | POSTGRESQL_POSTGRES_PASSWORD: postgres 68 | 69 | ports: 70 | # Maps tcp port 5432 on service container to the host 71 | - 5432:5432 72 | 73 | steps: 74 | - name: Checkout 75 | uses: actions/checkout@v2 76 | - name: Use Node.js ${{ matrix.node-version }} 77 | uses: actions/setup-node@v1 78 | with: 79 | node-version: '13.x' 80 | - run: | 81 | npm install -g knex & 82 | npm install & 83 | wait 84 | - run: | 85 | echo $TESTS_ENV | base64 --decode --ignore-garbage > .env && 86 | source .env && npm test 87 | env: 88 | # The hostname used to communicate with the PostgreSQL service container 89 | POSTGRES_HOST: localhost 90 | # The default PostgreSQL port 91 | POSTGRES_PORT: 5432 92 | -------------------------------------------------------------------------------- /app/lib/writeToGCSBucket.js: -------------------------------------------------------------------------------- 1 | // const { GoogleAuth } = require('google-auth-library'); 2 | const { Storage } = require('@google-cloud/storage'); 3 | const { SecretManagerServiceClient } = require('@google-cloud/secret-manager'); 4 | const fs = require('fs'); 5 | 6 | const client = new SecretManagerServiceClient(); 7 | 8 | /** 9 | * 10 | * Example of writing to a Google Cloud Storage (GCS) Bucket 11 | * 12 | * This is simple logic that will save the published files that are needed 13 | * for the Mobile apps to download data from. You will want to create your own 14 | * logic here and save to a public location. Your organizations API Endpoint 15 | * should point to the directory that the cursor.json file is located in. 16 | * 17 | * @method writePublishedFiles 18 | * @param {Object} pages 19 | * @return {Boolean} 20 | */ 21 | 22 | async function pullSecret() { 23 | if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { 24 | console.log('[GCS] Using Application Credentials'); 25 | return true; 26 | } 27 | 28 | if (!process.env.GOOGLE_SECRET) { 29 | console.log('[GCS] Google Secret is invalid, falling back.'); 30 | return true; 31 | } 32 | 33 | const saveCredentialsFile = (file, contents) => { 34 | return new Promise((resolve, reject) => { 35 | fs.writeFile(file, contents, err => { 36 | if (err) reject(err); 37 | resolve(true); 38 | }); 39 | }); 40 | }; 41 | 42 | const fileName = '/tmp/creds.json'; 43 | 44 | const [accessResponse] = await client.accessSecretVersion({ 45 | name: process.env.GOOGLE_SECRET, 46 | }); 47 | if (accessResponse) { 48 | const responsePayload = accessResponse.payload.data.toString('utf8'); 49 | if (responsePayload) { 50 | const credsSaved = await saveCredentialsFile(fileName, responsePayload); 51 | if (credsSaved) { 52 | process.env.GOOGLE_APPLICATION_CREDENTIALS = fileName; 53 | return true; 54 | } else { 55 | throw new Error('Problem saving credentials file.'); 56 | } 57 | } else { 58 | throw new Error('Problem getting access secret response.'); 59 | } 60 | } else { 61 | throw new Error('Access Response is invalid.'); 62 | } 63 | } 64 | 65 | module.exports = async pages => { 66 | if (!process.env.GCLOUD_STORAGE_BUCKET) 67 | throw new Error('Google Bucket not set.'); 68 | 69 | let path = ''; 70 | if (process.env.GCLOUD_STORAGE_PATH) { 71 | path = process.env.GCLOUD_STORAGE_PATH + '/'; 72 | } 73 | 74 | console.log(`[GCS] Bucket: `, process.env.GCLOUD_STORAGE_BUCKET); 75 | console.log(`[GCS] Path: `, path); 76 | 77 | const secret = await pullSecret(); 78 | if (secret) { 79 | const storage = new Storage(); 80 | const bucket = storage.bucket(process.env.GCLOUD_STORAGE_BUCKET); 81 | 82 | const saveFile = (filename, contents) => { 83 | return new Promise((resolve, reject) => { 84 | const blob = bucket.file(filename); 85 | const stream = blob.createWriteStream({ resumable: false }); 86 | stream.on('error', err => reject(err)); 87 | stream.on('finish', () => { 88 | console.log( 89 | `[GCS] Full Path: https://storage.googleapis.com/${bucket.name}/${path}${blob.name}`, 90 | ); 91 | resolve( 92 | `https://storage.googleapis.com/${bucket.name}/${path}${blob.name}`, 93 | ); 94 | }); 95 | stream.end(Buffer.from(contents)); 96 | }); 97 | }; 98 | 99 | await saveFile(`${path}cursor.json`, JSON.stringify(pages.cursor)); 100 | 101 | for (let page of pages.files) { 102 | const filename = page.page_name.split('/').pop(); 103 | await saveFile(path + filename, JSON.stringify(page)); 104 | } 105 | } else { 106 | throw new Error('Secrets file could not be generated'); 107 | } 108 | 109 | return true; 110 | }; 111 | -------------------------------------------------------------------------------- /app/api/organization/controller.js: -------------------------------------------------------------------------------- 1 | const { caseService, organizationService } = require('../../../app/lib/db'); 2 | const _ = require('lodash'); 3 | const moment = require('moment'); 4 | 5 | /** 6 | * @method fetchOrganization 7 | * 8 | * Fetch Organization 9 | * 10 | */ 11 | exports.fetchOrganizationById = async (req, res) => { 12 | const { 13 | user: { organization_id }, 14 | } = req; 15 | 16 | if (!organization_id) throw new Error('Organization ID is missing.'); 17 | 18 | const organization = await organizationService.fetchById(organization_id); 19 | if (organization) { 20 | res 21 | .status(200) 22 | .json( 23 | _.pick(organization, [ 24 | 'id', 25 | 'externalId', 26 | 'name', 27 | 'completedOnboarding', 28 | ]), 29 | ); 30 | } else { 31 | throw new Error(`Could not fetch organization by id ${organization_id}.`); 32 | } 33 | }; 34 | 35 | /** 36 | * @method fetchOrganizationConfig 37 | * 38 | * Fetch Organization config information. 39 | * 40 | */ 41 | exports.fetchOrganizationConfig = async (req, res) => { 42 | const { 43 | user: { organization_id }, 44 | } = req; 45 | 46 | if (!organization_id) throw new Error('Organization ID is missing.'); 47 | 48 | const organization = await organizationService.fetchById(organization_id); 49 | 50 | let responsePayload = _.pick(organization, [ 51 | 'id', 52 | 'externalId', 53 | 'name', 54 | 'completedOnboarding', 55 | 'notificationThresholdPercent', 56 | 'notificationThresholdTimeline', 57 | 'daysToRetainRecords', 58 | 'regionCoordinates', 59 | 'apiEndpointUrl', 60 | 'referenceWebsiteUrl', 61 | 'infoWebsiteUrl', 62 | 'privacyPolicyUrl', 63 | ]); 64 | 65 | responsePayload.appVersion = process.env.npm_package_version; 66 | 67 | if (organization) { 68 | res.status(200).json(responsePayload); 69 | } else { 70 | throw new Error( 71 | `Could not fetch organization config by users org id ${organization_id}.`, 72 | ); 73 | } 74 | }; 75 | 76 | /** 77 | * @method updateOrganization 78 | * 79 | * Update Organization 80 | * 81 | */ 82 | exports.updateOrganization = async (req, res) => { 83 | const { 84 | user: { organization_id }, 85 | body: organization, 86 | } = req; 87 | 88 | if (!organization_id) throw new Error('Organization ID is missing.'); 89 | 90 | const results = await organizationService.updateOrganization( 91 | organization_id, 92 | organization, 93 | ); 94 | if (results) { 95 | res.status(200).json(results); 96 | } else { 97 | throw new Error( 98 | `Could not update organization by users org id ${organization_id} and paramaters.`, 99 | ); 100 | } 101 | }; 102 | 103 | /** 104 | * @method fetchOrganizationCases 105 | * 106 | * Fetch cases associated with organization. 107 | * Organization is pulled from the user. 108 | * 109 | */ 110 | exports.fetchOrganizationCases = async (req, res) => { 111 | const { 112 | user: { organization_id }, 113 | } = req; 114 | 115 | if (!organization_id) throw new Error('Organization ID is missing.'); 116 | 117 | await organizationService.cleanOutExpiredCases(organization_id); 118 | 119 | const cases = await organizationService.getCases(organization_id); 120 | 121 | res.status(200).json({ cases }); 122 | }; 123 | 124 | /** 125 | * @method createOrganizationCase 126 | * 127 | * Create cases associated with organization. 128 | * Organization is pulled from the user. 129 | * 130 | */ 131 | exports.createOrganizationCase = async (req, res) => { 132 | const { 133 | user: { id, organization_id }, 134 | } = req; 135 | 136 | if (!id) throw new Error('User ID is missing.'); 137 | if (!organization_id) throw new Error('Organization ID is missing.'); 138 | 139 | const organization = await organizationService.fetchById(organization_id); 140 | if (!organization) throw new Error('Organization could not be found.'); 141 | 142 | const newCase = await caseService.createCase({ 143 | contact_tracer_id: id, 144 | organization_id, 145 | expires_at: moment() 146 | .startOf('day') 147 | .add(organization.daysToRetainRecords, 'days') 148 | .format(), 149 | state: 'unpublished', 150 | }); 151 | 152 | if (newCase) { 153 | res.status(200).json(newCase); 154 | } else { 155 | throw new Error( 156 | `Could not create case by users org id ${organization_id}.`, 157 | ); 158 | } 159 | }; 160 | -------------------------------------------------------------------------------- /test/integration/login.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | const atob = require('atob'); 3 | const chai = require('chai'); 4 | const chaiHttp = require('chai-http'); 5 | const expect = chai.expect; 6 | 7 | const app = require('../../app'); 8 | const server = app.getTestingServer(); 9 | 10 | chai.use(chaiHttp); 11 | 12 | function parseJwt(token) { 13 | const base64Url = token.split('.')[1]; 14 | const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 15 | const jsonPayload = decodeURIComponent( 16 | atob(base64) 17 | .split('') 18 | .map(function (c) { 19 | return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); 20 | }) 21 | .join(''), 22 | ); 23 | 24 | return JSON.parse(jsonPayload); 25 | } 26 | 27 | describe('POST /auth/login', function () { 28 | this.timeout(5000); 29 | 30 | it('should login on admin user creds', function (done) { 31 | chai 32 | .request(server) 33 | .post('/auth/login') 34 | .send({ 35 | username: 'safeplaces@extremesolution.com', 36 | password: 'Wx$sRj3E', 37 | }) 38 | .end(function (err, res) { 39 | const ns = process.env.AUTH0_CLAIM_NAMESPACE; 40 | 41 | expect(res.status).to.equal(200); 42 | expect(res.body).to.haveOwnProperty('id'); 43 | 44 | const accessToken = /access_token=([a-zA-Z0-9.\-_]+);/g.exec( 45 | res.header['set-cookie'], 46 | )[1]; 47 | 48 | const parsedJwt = parseJwt(accessToken); 49 | expect(parsedJwt).to.haveOwnProperty('sub'); 50 | expect(parsedJwt.sub).to.equal('auth0|5f246391675616003785f947'); 51 | expect(parsedJwt).to.haveOwnProperty(`${ns}/roles`); 52 | expect(parsedJwt[`${ns}/roles`]).to.have.members(['admin']); 53 | expect(parsedJwt).to.haveOwnProperty('iat'); 54 | 55 | chai.assert.equal(new Date(parsedJwt.iat * 1000) instanceof Date, true); 56 | expect(parsedJwt).to.haveOwnProperty('exp'); 57 | chai.assert.equal(new Date(parsedJwt.exp * 1000) instanceof Date, true); 58 | 59 | return done(); 60 | }); 61 | }); 62 | 63 | it('should login on contact tracer user creds', function (done) { 64 | chai 65 | .request(server) 66 | .post('/auth/login') 67 | .send({ 68 | username: 'tracer@extremesolution.com', 69 | password: 'cX#Ee7sR', 70 | }) 71 | .end(function (err, res) { 72 | const ns = process.env.AUTH0_CLAIM_NAMESPACE; 73 | 74 | expect(res.status).to.equal(200); 75 | expect(res.body).to.haveOwnProperty('id'); 76 | 77 | const accessToken = /access_token=([a-zA-Z0-9.\-_]+);/g.exec( 78 | res.header['set-cookie'], 79 | )[1]; 80 | 81 | const parsedJwt = parseJwt(accessToken); 82 | expect(parsedJwt).to.haveOwnProperty('sub'); 83 | expect(parsedJwt.sub).to.equal('auth0|5f1f0f32314999003d05021e'); 84 | expect(parsedJwt).to.haveOwnProperty(`${ns}/roles`); 85 | expect(parsedJwt[`${ns}/roles`]).to.have.members(['contact_tracer']); 86 | expect(parsedJwt).to.haveOwnProperty('iat'); 87 | 88 | chai.assert.equal(new Date(parsedJwt.iat * 1000) instanceof Date, true); 89 | expect(parsedJwt).to.haveOwnProperty('exp'); 90 | chai.assert.equal(new Date(parsedJwt.exp * 1000) instanceof Date, true); 91 | 92 | return done(); 93 | }); 94 | }); 95 | 96 | /* 97 | it('should fail when wrong password is given saying creds are invalid', function (done) { 98 | chai 99 | .request(server) 100 | .post('/auth/login') 101 | .send({ 102 | username: 'safeplaces@extremesolution.com', 103 | password: 'wrongpassword', 104 | }) 105 | .end(function (err, res) { 106 | expect(res.status).to.equal(401); 107 | expect(res.text).to.be.a('string'); 108 | expect(res.text).to.equal('Unauthorized'); 109 | return done(); 110 | }); 111 | }); 112 | 113 | it('should fail with invalid username saying creds are invalid', function (done) { 114 | chai 115 | .request(server) 116 | .post('/auth/login') 117 | .send({ 118 | username: 'wronguser', 119 | password: 'password', 120 | }) 121 | .end(function (err, res) { 122 | expect(res.status).to.equal(401); 123 | expect(res.text).to.be.a('string'); 124 | expect(res.text).to.equal('Unauthorized'); 125 | return done(); 126 | }); 127 | }); 128 | */ 129 | }); 130 | -------------------------------------------------------------------------------- /.github/workflows/google-express-tests.yaml: -------------------------------------------------------------------------------- 1 | # This workflow will build a docker container, publish it to Google Container Registry, and deploy it to GKE when a release is created 2 | # 3 | # To configure this workflow: 4 | # 5 | # 1. Ensure that your repository contains the necessary configuration for your Google Kubernetes Engine cluster, including deployment.yml, kustomization.yml, service.yml, etc. 6 | # 7 | # 2. Set up secrets in your workspace: GKE_PROJECT with the name of the project, GKE_EMAIL with the service account email, GKE_KEY with the Base64 encoded JSON service account key (https://github.com/GoogleCloudPlatform/github-actions/tree/docs/service-account-key/setup-gcloud#inputs). 8 | # 9 | # 3. Change the values for the GKE_ZONE, GKE_CLUSTER, IMAGE, REGISTRY_HOSTNAME and DEPLOYMENT_NAME environment variables (below). 10 | 11 | name: Newman Tests 12 | 13 | on: 14 | pull_request: 15 | branches: 16 | - dev 17 | # Environment variables available to all jobs and steps in this workflow 18 | env: 19 | GKE_PROJECT: ${{ secrets.GKE_PROJECT }} 20 | GKE_EMAIL: ${{ secrets.GKE_EMAIL }} 21 | GITHUB_SHA: ${{ github.sha }} 22 | GKE_ZONE: ${{ secrets.GKE_ZONE }} 23 | GKE_CLUSTER: ${{ secrets.GKE_CLUSTER }} 24 | IMAGE: safeplaces-be-express-test 25 | REGISTRY_HOSTNAME: gcr.io 26 | DEPLOYMENT_NAME: ${{ secrets.XP_TEST_DEPLOYMENT_NAME }} 27 | CONTAINER_NAME: ${{ secrets.XP_TEST_CONTAINER_NAME }} 28 | NAMESPACE: ${{ secrets.GKE_NAMESPACE }} 29 | TESTS_ENV: ${{ secrets.TESTS_ENV }} 30 | NODE_ENV: test 31 | AUTH_LOGOUT_REDIRECT_URL: 'http://127.0.0.1:5000' 32 | AUTH0_BASE_URL: 'https://pathcheck.us.auth0.com' 33 | AUTH0_CLIENT_ID: 'Dgy5n22Gcuo3zRHzWLk19CCZ9bUNbVhU' 34 | AUTH0_CLIENT_SECRET: 'fffp66sf-Qppw9xsC1pzUREzkdNGTz-8H8DdesL0sgGO74vivJs_NyhcDLQZjtJb' 35 | AUTH0_API_AUDIENCE: 'https://safeplaces.cloud/' 36 | AUTH0_CLAIM_NAMESPACE: 'https://safeplaces.cloud' 37 | AUTH0_MANAGEMENT_API_AUDIENCE: ${{ secrets.AUTH0_MANAGEMENT_API_AUDIENCE }} 38 | AUTH0_MANAGEMENT_CLIENT_ID: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_ID }} 39 | AUTH0_MANAGEMENT_CLIENT_SECRET: ${{ secrets.AUTH0_MANAGEMENT_CLIENT_SECRET }} 40 | AUTH0_REALM: test 41 | 42 | jobs: 43 | build-push-artifact: 44 | name: Run Newman Tests 45 | runs-on: ubuntu-latest 46 | defaults: 47 | run: 48 | shell: bash 49 | # working-directory: expressjs 50 | steps: 51 | 52 | - name: Checkout 53 | uses: actions/checkout@v2 54 | 55 | # Setup gcloud CLI 56 | - uses: GoogleCloudPlatform/github-actions/setup-gcloud@master 57 | with: 58 | version: '290.0.0' 59 | service_account_email: ${{ secrets.GKE_EMAIL }} 60 | service_account_key: ${{ secrets.GKE_KEY }} 61 | project_id: ${{ secrets.GKE_PROJECT }} 62 | export_default_credentials: true 63 | 64 | # Configure docker to use the gcloud command-line tool as a credential helper 65 | - run: | 66 | # Set up docker to authenticate 67 | # via gcloud command-line tool. 68 | gcloud auth configure-docker 69 | 70 | # Build the Docker image 71 | - name: Build 72 | run: | 73 | docker build -t $REGISTRY_HOSTNAME/${{ secrets.GKE_PROJECT }}/$IMAGE:$GITHUB_SHA \ 74 | --build-arg GITHUB_SHA=$GITHUB_SHA \ 75 | --build-arg GITHUB_REF=$GITHUB_REF . 76 | 77 | # Push the Docker image to Google Container Registry 78 | - name: Publish 79 | run: | 80 | docker push $REGISTRY_HOSTNAME/$GKE_PROJECT/$IMAGE:$GITHUB_SHA 81 | 82 | # Set up kustomize 83 | - name: Set up Kustomize 84 | run: |- 85 | curl -sfLo kustomize https://github.com/kubernetes-sigs/kustomize/releases/download/v3.1.0/kustomize_3.1.0_linux_amd64 86 | chmod u+x ./kustomize 87 | 88 | # Deploy the Docker image to the GKE cluster 89 | - name: Deploy Test Environment 90 | run: | 91 | gcloud container clusters get-credentials $GKE_CLUSTER --zone $GKE_ZONE --project $GKE_PROJECT 92 | kubectl config set-context --current --namespace=${NAMESPACE} 93 | cd base/env/test && ../../../kustomize edit set image gcr.io/PROJECT_ID/IMAGE:TAG=gcr.io/$GKE_PROJECT/$IMAGE:$GITHUB_SHA 94 | ../../../kustomize build . | kubectl apply -f - --namespace=${NAMESPACE} 95 | kubectl rollout status deployment $DEPLOYMENT_NAME --namespace ${NAMESPACE} 96 | kubectl get services -o wide 97 | 98 | - uses: matt-ball/newman-action@master 99 | with: 100 | collection: tests/tests/backend_collection.json 101 | environment: tests/tests/FASTAPI.postman_environment.json 102 | -------------------------------------------------------------------------------- /app/lib/publicationFiles.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const AdmZip = require('adm-zip'); 3 | const crypto = require('crypto'); 4 | 5 | /** 6 | * @class PublicationFiles 7 | * 8 | * Using trails data, generate a set of JSON files the HA can point to. 9 | * 10 | */ 11 | 12 | class PublicationFiles { 13 | /** 14 | * Build Publication File Content 15 | * 16 | * @method build 17 | * @param {Object} organization 18 | * @param {Object} record 19 | * @param {Array[Trails]} trails 20 | * @return {Object} 21 | */ 22 | async build(organization, record, trails) { 23 | if (!organization.apiEndpointUrl) 24 | throw new Error('Your API endpoint is invalid.'); 25 | 26 | let endpoint = organization.apiEndpointUrl; 27 | if (endpoint.substr(endpoint.length - 1, 1) !== '/') { 28 | endpoint = '/'; 29 | } 30 | this._apiEndpointPage = `${endpoint}[PAGE].json`; 31 | 32 | const header = this._getHeader(organization, record); 33 | const trailsChunked = this._chunkTrails( 34 | trails, 35 | organization.chunkingInSeconds, 36 | ); 37 | const cursor = this._getCursorInformation( 38 | organization, 39 | trailsChunked, 40 | _.clone(header), 41 | ); 42 | const files = trailsChunked.map(chunk => { 43 | const newHeader = _.clone(header); 44 | newHeader.concern_point_hashes = this._getPointHashes(chunk); 45 | if (process.env.NODE_ENV !== 'production' && process.env.HASHING_TEST) 46 | newHeader.points_for_test = chunk.trails; 47 | newHeader.page_name = this._apiEndpointPage.replace( 48 | '[PAGE]', 49 | `${chunk.startTimestamp}_${chunk.endTimestamp}`, 50 | ); 51 | return newHeader; 52 | }); 53 | 54 | return { files, cursor }; 55 | } 56 | 57 | /** 58 | * 59 | * Build Publication File Content and return zip file Buffer. 60 | * 61 | * @method build 62 | * @param {Object} organization 63 | * @param {Object} record 64 | * @param {Array[Trails]} trails 65 | * @return {ZipFile} 66 | */ 67 | 68 | async buildAndZip(organization, record, trails) { 69 | const pages = await this.build(organization, record, trails); 70 | if (pages) { 71 | const zip = new AdmZip(); 72 | 73 | let filename; 74 | zip.addFile( 75 | 'instructions.txt', 76 | 'Place all files in the `trails` folder onto your web server.', 77 | ); 78 | zip.addFile('trails/', Buffer.from('')); 79 | zip.addFile( 80 | `trails/cursor.json`, 81 | Buffer.from(JSON.stringify(pages.cursor)), 82 | ); 83 | pages.files.forEach(page => { 84 | filename = page.page_name.split('/').pop(); 85 | zip.addFile(`trails/${filename}`, Buffer.from(JSON.stringify(page))); 86 | }); 87 | 88 | return zip.toBuffer(); 89 | } 90 | throw new Error('Problem generating the zip file.'); 91 | } 92 | 93 | // private 94 | 95 | /** 96 | * 97 | * Fetch Header 98 | * 99 | * @method _getHeader 100 | * @param {Object} organization 101 | * @param {Object} record 102 | * @return {Object} 103 | */ 104 | 105 | _getHeader(organization, record) { 106 | return { 107 | version: '1.1', 108 | name: organization.name, 109 | publish_date_utc: record.publish_date.getTime(), 110 | info_website_url: organization.infoWebsiteUrl, 111 | api_endpoint_url: organization.apiEndpointUrl, 112 | privacy_policy_url: organization.privacyPolicyUrl, 113 | reference_website_url: organization.referenceWebsiteUrl, 114 | notification_threshold_percent: organization.notificationThresholdPercent, 115 | notification_threshold_timeframe: 116 | organization.notificationThresholdTimeline, 117 | }; 118 | } 119 | 120 | /** 121 | * Get all information related to paginating. 122 | * 123 | * @private 124 | * @method _getPaginationInformation 125 | * @param {Object} organization 126 | * @param {Array} trails 127 | * @param {Number} currentPage 128 | * @return {Object} 129 | */ 130 | _getCursorInformation(organization, trails, header) { 131 | const pages = trails.map(chunk => { 132 | return { 133 | id: `${chunk.startTimestamp}_${chunk.endTimestamp}`, 134 | startTimestamp: chunk.startTimestamp, 135 | endTimestamp: chunk.endTimestamp, 136 | filename: this._apiEndpointPage.replace( 137 | '[PAGE]', 138 | `${chunk.startTimestamp}_${chunk.endTimestamp}`, 139 | ), 140 | checksum: crypto 141 | .createHash('md5') 142 | .update(JSON.stringify(chunk.trails)) 143 | .digest('hex'), 144 | }; 145 | }); 146 | header.pages = pages; 147 | return header; 148 | } 149 | 150 | /** 151 | * Get all hashes related to this page. 152 | * 153 | * @private 154 | * @method _getPointHashes 155 | * @param {Object} chunk 156 | * @return {Array} 157 | */ 158 | _getPointHashes(chunk) { 159 | return chunk.trails.map(trail => trail.hash); 160 | } 161 | 162 | /** 163 | * Build pages based on chunking time. 164 | * Remember, the time we are looking at is the Published time for the case, not the time 165 | * of the point. 166 | * 167 | * @private 168 | * @method _chunkTrails 169 | * @param {Array} trails 170 | * @param {Number} seconds 171 | * @return {Array} 172 | */ 173 | _chunkTrails(trails, seconds) { 174 | if (trails.length === 0) { 175 | return [ 176 | { 177 | page: 1, 178 | startTimestamp: null, 179 | endTimestamp: null, 180 | trails: [], 181 | }, 182 | ]; 183 | } 184 | 185 | const shuffle = array => { 186 | let currentIndex = array.length; 187 | let temporaryValue, randomIndex; 188 | while (0 !== currentIndex) { 189 | randomIndex = Math.floor(Math.random() * currentIndex); 190 | currentIndex -= 1; 191 | temporaryValue = array[currentIndex]; 192 | array[currentIndex] = array[randomIndex]; 193 | array[randomIndex] = temporaryValue; 194 | } 195 | return array; 196 | }; 197 | 198 | // Get Publication dates, and sort them. 199 | let publicationDates = [ 200 | ...new Set(trails.map(trail => new Date(trail.publishDate).getTime())), 201 | ]; 202 | publicationDates = publicationDates 203 | .sort((a, b) => (a > b ? 1 : b > a ? -1 : 0)) 204 | .reverse(); // Assure they are sorted properly. 205 | 206 | // Goto 1 second before Midnight of the most recent publication 207 | let startTime = 208 | new Date(publicationDates[0]).setHours(0, 0, 0, 0) + 86400000 - 1000; 209 | let lastPublicationTimestamp = 210 | publicationDates[publicationDates.length - 1]; 211 | 212 | // Work backwards baseed on the chunking time. 213 | let endTimestamp; 214 | let i = 0; 215 | let groups = []; 216 | let timeGroup = startTime; 217 | while (startTime) { 218 | endTimestamp = timeGroup - seconds * 1000 + 1000; 219 | groups.push({ 220 | page: i + 1, 221 | startTimestamp: timeGroup, 222 | endTimestamp: endTimestamp, 223 | trails: [], 224 | }); 225 | timeGroup = timeGroup - seconds * 1000; 226 | i++; 227 | 228 | if (lastPublicationTimestamp >= endTimestamp) break; 229 | } 230 | 231 | // Find Trails. We are checking the publish time of the publication that is associated with the 232 | // case and therefore the trail. 233 | // Remove any empty groups that don't have any trails associated with them. 234 | groups = groups 235 | .map(group => { 236 | group.trails = trails.filter(trail => { 237 | const publishDateTs = new Date(trail.publishDate).getTime(); 238 | if ( 239 | publishDateTs <= group.startTimestamp && 240 | publishDateTs >= group.endTimestamp 241 | ) { 242 | return true; 243 | } 244 | return false; 245 | }); 246 | return group; 247 | }) 248 | .filter(group => group.trails.length > 0); 249 | 250 | // Sort all trails by time for privacy. 251 | return groups.map(group => { 252 | group.trails = shuffle(group.trails); 253 | return group; 254 | }); 255 | } 256 | } 257 | 258 | module.exports = new PublicationFiles(); 259 | -------------------------------------------------------------------------------- /app/api/case/controller.js: -------------------------------------------------------------------------------- 1 | const { 2 | caseService, 3 | organizationService, 4 | publicationService, 5 | } = require('../../../app/lib/db'); 6 | 7 | const publicationFiles = require('../../lib/publicationFiles'); 8 | 9 | const writePublishedFiles = require('../../lib/writePublishedFiles'); 10 | const writeToGCSBucket = require('../../lib/writeToGCSBucket'); 11 | const writeToS3Bucket = require('../../lib/writeToS3Bucket'); 12 | 13 | /** 14 | * @method consentToPublish 15 | * 16 | * Captures user consent to having their data published in the 17 | * aggregated anonymized JSON file that is available to public. 18 | * 19 | */ 20 | exports.consentToPublish = async (req, res) => { 21 | const { caseId } = req.body; 22 | 23 | if (!caseId) throw new Error('Case ID is not valid.'); 24 | 25 | const caseResult = await caseService.consentToPublishing(caseId); 26 | 27 | if (caseResult) { 28 | res.status(200).json({ case: caseResult }); 29 | } else { 30 | throw new Error( 31 | `Could not set consent to publishing for case id ${caseId}.`, 32 | ); 33 | } 34 | }; 35 | 36 | /** 37 | * @method setCaseToStaging 38 | * 39 | * Updates the state of the case from unpublished to staging. 40 | * 41 | */ 42 | exports.setCaseToStaging = async (req, res) => { 43 | const { caseId } = req.body; 44 | 45 | if (!caseId) throw new Error('Case ID is not valid.'); 46 | 47 | const caseResults = await caseService.moveToStaging(caseId); 48 | 49 | if (caseResults) { 50 | res.status(200).json({ case: caseResults }); 51 | } else { 52 | throw new Error(`Could not set case to staging for case id ${caseId}.`); 53 | } 54 | }; 55 | 56 | /** 57 | * @method publishCases 58 | * 59 | * Moves the state of the cases from staging to published and 60 | * generates JSON file containing aggregated anonymized points 61 | * of concern data. JSON file is then pushed to the endpoint 62 | * responsible for hosting the published data (this functionality 63 | * is implemented by HA). 64 | * 65 | * DONE - Fetch Orgnization 66 | * DONE - Publish case ids passed in. 67 | * DONE - Create Publication 68 | * DONE - Updated new cases with Publication ID 69 | * DONE - Fetch Points for Cases that are published and have not exipired along with all points. 70 | * DONE - Build Files 71 | * 72 | * Many options when talking about response, and it's all triggered by passing in the type query param. 73 | * By default, we will write to a GCS bucket. Other options include, that do not work in production: 74 | * 75 | * zip = Return a zip file 76 | * json = Return a JSON payload of what goes into the files that are generated 77 | * local = Save to local server environment 78 | * 79 | */ 80 | 81 | exports.publishCases = async (req, res) => { 82 | const { 83 | body: { caseIds }, 84 | user: { organization_id }, 85 | } = req; 86 | let { 87 | query: { type }, 88 | } = req; 89 | 90 | type = type || process.env.PUBLISH_STORAGE_TYPE; 91 | 92 | if (!caseIds) throw new Error('Case IDs are invalid.'); 93 | if (!organization_id) throw new Error('Organization ID is not valid.'); 94 | 95 | const organization = await organizationService.fetchById(organization_id); 96 | if (organization) { 97 | console.log( 98 | `[PUBLISH] Starting to publish case ids ${caseIds.join(', ')}...`, 99 | ); 100 | 101 | const cases = await caseService.publishCases(caseIds, organization.id); 102 | 103 | console.log( 104 | `[PUBLISH] Completed publishing case ids ${caseIds.join(', ')}.`, 105 | ); 106 | 107 | const publicationParams = { 108 | organization_id: organization.id, 109 | publish_date: new Date(), 110 | }; 111 | const publication = await publicationService.insert(publicationParams); 112 | if (publication) { 113 | const casesUpdateResults = await caseService.updateCasePublicationId( 114 | caseIds, 115 | publication.id, 116 | ); 117 | if (!casesUpdateResults) { 118 | throw new Error( 119 | `Could not set case to staging for case id ${JSON.stringify( 120 | caseIds, 121 | )} and publication ${publication.id}.`, 122 | ); 123 | } 124 | 125 | console.log(`[PUBLISH] About to pull points for all published points.`); 126 | 127 | // Everything has been published and assigned...pull all published points. 128 | let points = await caseService.fetchAllPublishedPoints(); 129 | 130 | console.log(`[PUBLISH] Found ${points.length} total points to publish.`); 131 | 132 | if (points && points.length > 0) { 133 | if (type === 'zip' && process.env.NODE_ENV !== 'production') { 134 | let data = await publicationFiles.buildAndZip( 135 | organization, 136 | publication, 137 | points, 138 | ); 139 | res 140 | .status(200) 141 | .set({ 142 | 'Content-Type': 'application/octet-stream', 143 | 'Content-Disposition': `attachment; filename="${publication.id}.zip"`, 144 | 'Content-Length': data.length, 145 | }) 146 | .send(data); 147 | return; 148 | } else { 149 | let pages = await publicationFiles.build( 150 | organization, 151 | publication, 152 | points, 153 | ); 154 | 155 | if (type === 'gcs') { 156 | const results = await writeToGCSBucket(pages); 157 | if (results) { 158 | res.status(200).json({ cases }); 159 | return; 160 | } else { 161 | throw new Error(`Could not write to GCS Bucket.`); 162 | } 163 | } else if (type === 'aws') { 164 | const results = await writeToS3Bucket(pages); 165 | if (results) { 166 | res.status(200).json({ cases }); 167 | return; 168 | } else { 169 | throw new Error(`Could not write to S3 Bucket.`); 170 | } 171 | } else if (process.env.NODE_ENV !== 'production') { 172 | if (type === 'json') { 173 | res.status(200).json(pages); 174 | return; 175 | } else if (type === 'local') { 176 | const results = await writePublishedFiles(pages, '/tmp/trails'); 177 | if (results) { 178 | res.status(200).json({ cases }); 179 | return; 180 | } 181 | throw new Error( 182 | 'Files could not be written to /tmp/trails folder.', 183 | ); 184 | } 185 | } 186 | } 187 | throw new Error('Files could not be written.'); 188 | } 189 | throw new Error('No points returned after cases were published.'); 190 | } else { 191 | throw new Error( 192 | 'Publication could not be generated using organization and publish date.', 193 | ); 194 | } 195 | } else { 196 | throw new Error(`Organization could not be found by id ${organization_id}`); 197 | } 198 | }; 199 | 200 | /** 201 | * @method deleteCase 202 | * 203 | * Delete Case Record 204 | * 205 | */ 206 | exports.deleteCase = async (req, res) => { 207 | const { caseId } = req.body; 208 | 209 | if (!caseId) throw new Error('Case ID is not valid.'); 210 | 211 | const caseResults = await caseService.deleteWhere({ id: caseId }); 212 | 213 | if (caseResults) { 214 | res.sendStatus(200); 215 | } else { 216 | throw new Error(`Could not delete case id ${caseId}.`); 217 | } 218 | }; 219 | 220 | /** 221 | * @method updateOrganizationCase 222 | * 223 | * Updates an existing case. 224 | * 225 | * 226 | */ 227 | exports.updateOrganizationCase = async (req, res) => { 228 | const { caseId, externalId } = req.body; 229 | 230 | if (!caseId) throw new Error('Case ID is missing.'); 231 | if (!externalId) throw new Error('External ID is missing.'); 232 | 233 | if (externalId) { 234 | const caseWithExternalId = await caseService.findOne({ 235 | external_id: externalId, 236 | }); 237 | 238 | if (caseWithExternalId && caseWithExternalId.id != caseId) { 239 | res.status(422).json({ error: 'External ID must be unique.' }); 240 | return; 241 | } 242 | } 243 | 244 | const results = await caseService.updateCaseExternalId(caseId, externalId); 245 | if (results) { 246 | res.status(200).json({ case: results }); 247 | } else { 248 | throw new Error( 249 | `Could not update case id ${caseId} with external id ${externalId}.`, 250 | ); 251 | } 252 | }; 253 | -------------------------------------------------------------------------------- /test/integration/organizations.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | process.env.DATABASE_URL = 3 | process.env.DATABASE_URL || 'postgres://localhost/safeplaces_test'; 4 | 5 | const { organizationService } = require('../../app/lib/db'); 6 | const chai = require('chai'); 7 | const should = chai.should(); // eslint-disable-line 8 | const chaiHttp = require('chai-http'); 9 | 10 | const mockData = require('../lib/mockData'); 11 | const mockAuth = require('../lib/mockAuth'); 12 | 13 | const app = require('../../app'); 14 | const server = app.getTestingServer(); 15 | 16 | chai.use(chaiHttp); 17 | 18 | let currentOrg, token, ctToken; 19 | 20 | describe('Organization ', () => { 21 | before(async () => { 22 | await mockData.clearMockData(); 23 | 24 | let orgParams = { 25 | name: 'My Example Organization', 26 | info_website_url: 'http://sample.com', 27 | notification_threshold_percent: 66, 28 | notification_threshold_timeframe: 30, 29 | days_to_retain_records: 14, 30 | region_coordinates: { 31 | ne: { latitude: 20.312764055951195, longitude: -70.45445121262883 }, 32 | sw: { latitude: 17.766025040122642, longitude: -75.49442923997258 }, 33 | }, 34 | api_endpoint_url: 'http://api.sample.com', 35 | reference_website_url: 'http://reference.sample.com', 36 | privacy_policy_url: 'http://privacy.reference.sample.com', 37 | completed_onboarding: true, 38 | }; 39 | currentOrg = await mockData.mockOrganization(orgParams); 40 | 41 | let newUserParams = { 42 | username: 'myAwesomeUser', 43 | organization_id: currentOrg.id, 44 | }; 45 | const user = await mockData.mockUser(newUserParams); 46 | token = mockAuth.getAccessToken(user.idm_id, 'admin'); 47 | ctToken = mockAuth.getAccessToken(user.idm_id, 'contact_tracer'); 48 | 49 | const caseParams = { 50 | organization_id: currentOrg.id, 51 | state: 'unpublished', 52 | }; 53 | 54 | await mockData.mockCase(caseParams); 55 | await mockData.mockCase(caseParams); 56 | await mockData.mockCase(caseParams); 57 | }); 58 | 59 | describe('GET /organization by user', () => { 60 | it('find the record just inserted using database', async () => { 61 | const results = await organizationService.fetchById(currentOrg.id); 62 | results.id.should.equal(currentOrg.id); 63 | }); 64 | 65 | it('fetch the record using http', async () => { 66 | const results = await chai 67 | .request(server) 68 | .get(`/organization`) 69 | .set('Cookie', `access_token=${token}`) 70 | .set('X-Requested-With', 'XMLHttpRequest') 71 | .set('content-type', 'application/json'); 72 | 73 | results.should.have.status(200); 74 | results.body.name.should.equal(currentOrg.name); 75 | results.body.id.should.equal(currentOrg.id); 76 | results.body.externalId.should.equal(currentOrg.externalId); 77 | results.body.completedOnboarding.should.equal( 78 | currentOrg.completedOnboarding, 79 | ); 80 | }); 81 | 82 | it('fetch the configuration using http', async () => { 83 | const results = await chai 84 | .request(server) 85 | .get(`/organization/configuration`) 86 | .set('Cookie', `access_token=${token}`) 87 | .set('X-Requested-With', 'XMLHttpRequest') 88 | .set('content-type', 'application/json'); 89 | 90 | results.should.have.status(200); 91 | results.body.id.should.equal(currentOrg.id); 92 | results.body.externalId.should.equal(currentOrg.externalId); 93 | results.body.name.should.equal(currentOrg.name); 94 | results.body.notificationThresholdPercent.should.equal( 95 | currentOrg.notificationThresholdPercent, 96 | ); 97 | results.body.notificationThresholdTimeline.should.equal( 98 | currentOrg.notificationThresholdTimeline, 99 | ); 100 | results.body.daysToRetainRecords.should.equal( 101 | currentOrg.daysToRetainRecords, 102 | ); 103 | results.body.regionCoordinates.ne.latitude.should.equal( 104 | currentOrg.regionCoordinates.ne.latitude, 105 | ); 106 | results.body.regionCoordinates.ne.longitude.should.equal( 107 | currentOrg.regionCoordinates.ne.longitude, 108 | ); 109 | results.body.regionCoordinates.sw.latitude.should.equal( 110 | currentOrg.regionCoordinates.sw.latitude, 111 | ); 112 | results.body.regionCoordinates.sw.longitude.should.equal( 113 | currentOrg.regionCoordinates.sw.longitude, 114 | ); 115 | results.body.apiEndpointUrl.should.equal(currentOrg.apiEndpointUrl); 116 | results.body.referenceWebsiteUrl.should.equal( 117 | currentOrg.referenceWebsiteUrl, 118 | ); 119 | results.body.privacyPolicyUrl.should.equal(currentOrg.privacyPolicyUrl); 120 | results.body.infoWebsiteUrl.should.equal(currentOrg.infoWebsiteUrl); 121 | results.body.completedOnboarding.should.equal( 122 | currentOrg.completedOnboarding, 123 | ); 124 | results.body.appVersion.should.equal(process.env.npm_package_version); 125 | }); 126 | 127 | it('update the record', async () => { 128 | const newParams = { 129 | name: 'Some Health Authority', 130 | notificationThresholdPercent: 66, 131 | notificationThresholdTimeline: 30, 132 | daysToRetainRecords: 14, 133 | regionCoordinates: { 134 | ne: { latitude: 20.312764055951195, longitude: -70.45445121262883 }, 135 | sw: { latitude: 17.766025040122642, longitude: -75.49442923997258 }, 136 | }, 137 | apiEndpointUrl: 'https://s3.aws.com/bucket_name/safepaths.json', 138 | referenceWebsiteUrl: 'http://cdc.gov', 139 | infoWebsiteUrl: 'http://cdc.gov', 140 | privacyPolicyUrl: 'https://superprivate.com', 141 | completedOnboarding: true, 142 | }; 143 | 144 | const results = await chai 145 | .request(server) 146 | .put(`/organization/configuration`) 147 | .set('Cookie', `access_token=${token}`) 148 | .set('X-Requested-With', 'XMLHttpRequest') 149 | .set('content-type', 'application/json') 150 | .send(newParams); 151 | 152 | results.should.have.status(200); 153 | results.body.should.be.a('object'); 154 | results.body.id.should.equal(currentOrg.id); 155 | results.body.externalId.should.equal(currentOrg.externalId); 156 | results.body.name.should.equal(newParams.name); 157 | results.body.infoWebsiteUrl.should.equal(newParams.infoWebsiteUrl); 158 | results.body.referenceWebsiteUrl.should.equal( 159 | newParams.referenceWebsiteUrl, 160 | ); 161 | results.body.apiEndpointUrl.should.equal(newParams.apiEndpointUrl); 162 | results.body.notificationThresholdPercent.should.equal( 163 | newParams.notificationThresholdPercent, 164 | ); 165 | results.body.notificationThresholdTimeline.should.equal( 166 | newParams.notificationThresholdTimeline, 167 | ); 168 | results.body.daysToRetainRecords.should.equal( 169 | newParams.daysToRetainRecords, 170 | ); 171 | results.body.privacyPolicyUrl.should.equal(newParams.privacyPolicyUrl); 172 | results.body.completedOnboarding.should.equal( 173 | newParams.completedOnboarding, 174 | ); 175 | }); 176 | 177 | it('cannot update record as a contact tracer', async () => { 178 | const newParams = { 179 | name: 'Some Health Authority', 180 | notificationThresholdPercent: 66, 181 | notificationThresholdTimeline: 30, 182 | daysToRetainRecords: 14, 183 | regionCoordinates: { 184 | ne: { latitude: 20.312764055951195, longitude: -70.45445121262883 }, 185 | sw: { latitude: 17.766025040122642, longitude: -75.49442923997258 }, 186 | }, 187 | apiEndpointUrl: 'https://s3.aws.com/bucket_name/safepaths.json', 188 | referenceWebsiteUrl: 'http://cdc.gov', 189 | infoWebsiteUrl: 'http://cdc.gov', 190 | privacyPolicyUrl: 'https://superprivate.com', 191 | completedOnboarding: true, 192 | }; 193 | 194 | const results = await chai 195 | .request(server) 196 | .put(`/organization/configuration`) 197 | .set('Cookie', `access_token=${ctToken}`) 198 | .set('X-Requested-With', 'XMLHttpRequest') 199 | .set('content-type', 'application/json') 200 | .send(newParams); 201 | 202 | results.should.have.status(403); 203 | results.text.should.eq('Forbidden'); 204 | }); 205 | 206 | it('fetch the organizationService cases', async () => { 207 | const results = await chai 208 | .request(server) 209 | .get(`/organization/cases`) 210 | .set('Cookie', `access_token=${token}`) 211 | .set('X-Requested-With', 'XMLHttpRequest') 212 | .set('content-type', 'application/json'); 213 | 214 | results.should.have.status(200); 215 | results.body.should.be.a('object'); 216 | results.body.should.have.property('cases'); 217 | results.body.cases.should.be.a('array'); 218 | results.body.cases.length.should.equal(3); 219 | 220 | const firstChunk = results.body.cases.shift(); 221 | firstChunk.should.have.property('caseId'); 222 | firstChunk.should.have.property('externalId'); 223 | firstChunk.should.have.property('contactTracerId'); 224 | firstChunk.should.have.property('state'); 225 | firstChunk.should.have.property('updatedAt'); 226 | firstChunk.should.have.property('expiresAt'); 227 | }); 228 | }); 229 | 230 | describe('create a case', () => { 231 | it('returns a 200', async () => { 232 | const results = await chai 233 | .request(server) 234 | .post(`/organization/case`) 235 | .set('Cookie', `access_token=${token}`) 236 | .set('X-Requested-With', 'XMLHttpRequest') 237 | .set('content-type', 'application/json') 238 | .send(); 239 | 240 | results.error.should.be.false; 241 | results.should.have.status(200); 242 | results.body.should.be.a('object'); 243 | results.body.should.have.property('caseId'); 244 | results.body.should.have.property('externalId'); 245 | results.body.should.have.property('contactTracerId'); 246 | results.body.should.have.property('state'); 247 | results.body.should.have.property('updatedAt'); 248 | results.body.should.have.property('expiresAt'); 249 | results.body.state.should.equal('unpublished'); 250 | }); 251 | }); 252 | }); 253 | -------------------------------------------------------------------------------- /tests/tests/backend_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "580d7aa5-a367-4a7d-928b-3165f67abdb2", 4 | "name": "Safe Places - Newman Test", 5 | "description": "The Safe Places API Specification", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | }, 8 | "item": [ 9 | { 10 | "name": "login ct", 11 | "event": [ 12 | { 13 | "listen": "test", 14 | "script": { 15 | "id": "c0081ad8-bf39-4370-a12e-a313be861611", 16 | "exec": [ 17 | "if (tests[\"Successful POST request\"] = responseCode.code === 200) {", 18 | " tests[\"Response time is less than 3s\"] = responseTime < 3000;", 19 | " var cookie = pm.response.headers.filter(header => header.key === 'Set-Cookie')[0].value;", 20 | " tests[\"Cookie is not empty\"] = !!cookie;", 21 | " var token = /access_token=([a-zA-Z0-9\\-_.]+);/g.exec(cookie)[1];", 22 | " tests[\"Token is not empty\"] = token.length > 0;", 23 | "}", 24 | "" 25 | ], 26 | "type": "text/javascript" 27 | } 28 | } 29 | ], 30 | "request": { 31 | "method": "POST", 32 | "header": [], 33 | "body": { 34 | "mode": "raw", 35 | "raw": "{\n \"username\": \"tracer@extremesolution.com\",\n \"password\": \"cX#Ee7sR\"\n}\n", 36 | "options": { 37 | "raw": { 38 | "language": "json" 39 | } 40 | } 41 | }, 42 | "url": { 43 | "raw": "{{baseUrl}}/auth/login", 44 | "host": ["{{baseUrl}}"], 45 | "path": ["auth", "login"] 46 | } 47 | }, 48 | "response": [] 49 | }, 50 | { 51 | "name": "organization/configuration", 52 | "event": [ 53 | { 54 | "listen": "test", 55 | "script": { 56 | "id": "c7cacc6e-0cf0-48f8-bfae-c1079305dfb5", 57 | "exec": [ 58 | "tests[\"Denies access to contact tracer\"] = responseCode.code === 403;" 59 | ], 60 | "type": "text/javascript" 61 | } 62 | } 63 | ], 64 | "request": { 65 | "method": "PUT", 66 | "header": [], 67 | "body": { 68 | "mode": "raw", 69 | "raw": "", 70 | "options": { 71 | "raw": { 72 | "language": "json" 73 | } 74 | } 75 | }, 76 | "url": { 77 | "raw": "{{baseUrl}}/organization/configuration", 78 | "host": ["{{baseUrl}}"], 79 | "path": ["organization", "configuration"] 80 | } 81 | }, 82 | "response": [] 83 | }, 84 | { 85 | "name": "organization/configuration", 86 | "event": [ 87 | { 88 | "listen": "test", 89 | "script": { 90 | "id": "a4fe93df-c6a7-4d45-b3f1-b75c389efcc8", 91 | "exec": [ 92 | "tests[\"Gets the organization configuration\"] = responseCode.code === 200;" 93 | ], 94 | "type": "text/javascript" 95 | } 96 | } 97 | ], 98 | "protocolProfileBehavior": { 99 | "disableBodyPruning": true 100 | }, 101 | "request": { 102 | "method": "GET", 103 | "header": [], 104 | "body": { 105 | "mode": "raw", 106 | "raw": "", 107 | "options": { 108 | "raw": { 109 | "language": "json" 110 | } 111 | } 112 | }, 113 | "url": { 114 | "raw": "{{baseUrl}}/organization/configuration", 115 | "host": ["{{baseUrl}}"], 116 | "path": ["organization", "configuration"] 117 | } 118 | }, 119 | "response": [] 120 | }, 121 | { 122 | "name": "login", 123 | "event": [ 124 | { 125 | "listen": "test", 126 | "script": { 127 | "id": "830fbb82-8539-4535-8849-43542c9fcef3", 128 | "exec": [ 129 | "if (tests[\"Successful POST request\"] = responseCode.code === 200) {\r", 130 | " tests[\"Response time is less than 3s\"] = responseTime < 3000;\r", 131 | " var cookie = pm.response.headers.filter(header => header.key === 'Set-Cookie')[0].value;\r", 132 | " tests[\"Cookie is not empty\"] = !!cookie;\r", 133 | " var token = /access_token=([a-zA-Z0-9\\-_.]+);/g.exec(cookie)[1];\r", 134 | " tests[\"Token is not empty\"] = token.length > 0;\r", 135 | "}\r", 136 | "" 137 | ], 138 | "type": "text/javascript" 139 | } 140 | } 141 | ], 142 | "request": { 143 | "auth": { 144 | "type": "noauth" 145 | }, 146 | "method": "POST", 147 | "header": [ 148 | { 149 | "key": "Content-Type", 150 | "value": "application/json" 151 | } 152 | ], 153 | "body": { 154 | "mode": "raw", 155 | "raw": "{\n \"username\": \"safeplaces@extremesolution.com\",\n \"password\": \"Wx$sRj3E\"\n}" 156 | }, 157 | "url": { 158 | "raw": "{{baseUrl}}/auth/login", 159 | "host": ["{{baseUrl}}"], 160 | "path": ["auth", "login"] 161 | } 162 | }, 163 | "response": [ 164 | { 165 | "name": "Untitled Response", 166 | "originalRequest": { 167 | "method": "POST", 168 | "header": [], 169 | "body": { 170 | "mode": "raw", 171 | "raw": "{\n \"username\": \"\",\n \"password\": \"\",\n \"code\": \"\"\n}" 172 | }, 173 | "url": { 174 | "raw": "{{baseUrl}}/login2", 175 | "host": ["{{baseUrl}}"], 176 | "path": ["login2"] 177 | } 178 | }, 179 | "status": "Created", 180 | "code": 201, 181 | "_postman_previewlanguage": "text", 182 | "header": [ 183 | { 184 | "key": "Content-Type", 185 | "value": "text/plain" 186 | } 187 | ], 188 | "cookie": [], 189 | "body": "" 190 | } 191 | ] 192 | }, 193 | { 194 | "name": "organization/case", 195 | "event": [ 196 | { 197 | "listen": "test", 198 | "script": { 199 | "id": "c992be8b-20af-4281-a49c-914029766714", 200 | "exec": [ 201 | "var jsonData = pm.response.json();\r", 202 | "\r", 203 | "pm.test(\"Status code is 200\", function () {\r", 204 | " pm.response.to.have.status(200);\r", 205 | "});\r", 206 | "\r", 207 | "//var count = Object.keys(jsonData.data[0].trail).length;\r", 208 | "\r", 209 | "\r", 210 | "pm.test(\"Check organization - identifier \", function () {\r", 211 | " pm.expect(jsonData.state).to.eql(\"unpublished\");\r", 212 | "});\r", 213 | "\r", 214 | "pm.test(\"Check organization - external_id \", function () {\r", 215 | " pm.expect(jsonData.external_id).not.to.eql(\"\");\r", 216 | "});\r", 217 | "\r", 218 | "\r", 219 | "pm.test(\"Check organization - caseId \", function () {\r", 220 | " pm.expect(jsonData.caseId).not.to.eql(\"\");\r", 221 | "});\r", 222 | "\r", 223 | "pm.test(\"Check organization - updatedAt \", function () {\r", 224 | " pm.expect(jsonData.updatedAt).not.to.eql(\"\");\r", 225 | "});\r", 226 | "\r", 227 | "pm.test(\"Check organization - expiresAt \", function () {\r", 228 | " pm.expect(jsonData.expiresAt).not.to.eql(\"\");\r", 229 | "});\r", 230 | "\r", 231 | "pm.test(\"Check organization - externalId \", function () {\r", 232 | " pm.expect(jsonData.externalId).not.to.eql(\"\");\r", 233 | "});\r", 234 | "\r", 235 | "pm.test(\"Check organization - contactTracerId \", function () {\r", 236 | " pm.expect(jsonData.contactTracerId).not.to.eql(\"\");\r", 237 | "});" 238 | ], 239 | "type": "text/javascript" 240 | } 241 | } 242 | ], 243 | "request": { 244 | "auth": { 245 | "type": "noauth" 246 | }, 247 | "method": "POST", 248 | "header": [ 249 | { 250 | "key": "Authorization", 251 | "value": "Bearer {{token}}", 252 | "type": "text" 253 | } 254 | ], 255 | "url": { 256 | "raw": "{{baseUrl}}/organization/case", 257 | "host": ["{{baseUrl}}"], 258 | "path": ["organization", "case"] 259 | } 260 | }, 261 | "response": [ 262 | { 263 | "name": "An intial \"tester\" (password: \"tester54321\") user is created.", 264 | "originalRequest": { 265 | "method": "POST", 266 | "header": [], 267 | "url": { 268 | "raw": "{{baseUrl}}/", 269 | "host": ["{{baseUrl}}"], 270 | "path": [""] 271 | } 272 | }, 273 | "status": "OK", 274 | "code": 200, 275 | "_postman_previewlanguage": "text", 276 | "header": [ 277 | { 278 | "key": "Content-Type", 279 | "value": "text/plain" 280 | } 281 | ], 282 | "cookie": [], 283 | "body": "" 284 | }, 285 | { 286 | "name": "Untitled Response", 287 | "originalRequest": { 288 | "method": "POST", 289 | "header": [], 290 | "url": { 291 | "raw": "{{baseUrl}}/", 292 | "host": ["{{baseUrl}}"], 293 | "path": [""] 294 | } 295 | }, 296 | "status": "Created", 297 | "code": 201, 298 | "_postman_previewlanguage": "json", 299 | "header": [ 300 | { 301 | "key": "Content-Type", 302 | "value": "application/json" 303 | } 304 | ], 305 | "cookie": [], 306 | "body": "\"\"" 307 | } 308 | ] 309 | }, 310 | { 311 | "name": "access-code", 312 | "event": [ 313 | { 314 | "listen": "test", 315 | "script": { 316 | "id": "c01a8767-bfe8-4d29-9f14-9fedeaed2f16", 317 | "exec": [ 318 | "var jsonData = pm.response.json(); \r", 319 | "\r", 320 | "pm.test(\"Status code is 201\", function () {\r", 321 | " pm.response.to.have.status(201);\r", 322 | "});\r", 323 | "\r", 324 | "pm.test(\"Check accessCode \", function () {\r", 325 | " pm.expect(jsonData.accessCode).not.to.eql(\"\");\r", 326 | "});\r", 327 | "\r", 328 | "pm.environment.set(\"code\", jsonData.accessCode);" 329 | ], 330 | "type": "text/javascript" 331 | } 332 | } 333 | ], 334 | "request": { 335 | "method": "POST", 336 | "header": [ 337 | { 338 | "key": "Authorization", 339 | "value": "Bearer {{token}}", 340 | "type": "text" 341 | } 342 | ], 343 | "body": { 344 | "mode": "raw", 345 | "raw": "{\n \"username\": \"safeplaes@extremesolution.com\",\n \"password\": \"Wx$sRj3E\"\n}", 346 | "options": { 347 | "raw": { 348 | "language": "json" 349 | } 350 | } 351 | }, 352 | "url": { 353 | "raw": "{{baseUrl}}/access-code", 354 | "host": ["{{baseUrl}}"], 355 | "path": ["access-code"] 356 | } 357 | }, 358 | "response": [ 359 | { 360 | "name": "Untitled Response", 361 | "originalRequest": { 362 | "method": "POST", 363 | "header": [], 364 | "body": { 365 | "mode": "raw", 366 | "raw": "{\n \"username\": \"\",\n \"password\": \"\"\n}" 367 | }, 368 | "url": { 369 | "raw": "{{baseUrl}}/validate", 370 | "host": ["{{baseUrl}}"], 371 | "path": ["validate"] 372 | } 373 | }, 374 | "status": "Created", 375 | "code": 201, 376 | "_postman_previewlanguage": "text", 377 | "header": [ 378 | { 379 | "key": "Content-Type", 380 | "value": "text/plain" 381 | } 382 | ], 383 | "cookie": [], 384 | "body": "" 385 | } 386 | ] 387 | } 388 | ], 389 | "event": [ 390 | { 391 | "listen": "prerequest", 392 | "script": { 393 | "id": "ad77a863-5c03-4609-8c55-21326dd8ffed", 394 | "type": "text/javascript", 395 | "exec": [ 396 | "pm.request.headers.add({ key: 'X-Requested-With', value: 'XMLHttpRequest' });", 397 | "" 398 | ] 399 | } 400 | }, 401 | { 402 | "listen": "test", 403 | "script": { 404 | "id": "4dfcad7e-e6c7-4100-9c50-6710e0363622", 405 | "type": "text/javascript", 406 | "exec": [""] 407 | } 408 | } 409 | ], 410 | "variable": [ 411 | { 412 | "id": "3e30b4b1-b0f2-45b9-bd38-8a08482c6f7f", 413 | "key": "baseUrl", 414 | "value": "/" 415 | } 416 | ], 417 | "protocolProfileBehavior": {} 418 | } 419 | -------------------------------------------------------------------------------- /test/lib/mockData.js: -------------------------------------------------------------------------------- 1 | const { 2 | accessCodeService, 3 | caseService, 4 | organizationService, 5 | pointService, 6 | publicationService, 7 | publicOrganizationService, 8 | settingService, 9 | uploadService, 10 | userService, 11 | } = require('../../app/lib/db'); 12 | 13 | const _ = require('lodash'); 14 | const moment = require('moment'); 15 | const { v4: uuidv4 } = require('uuid'); 16 | const randomCoordinates = require('random-coordinates'); 17 | const sinon = require('sinon'); 18 | 19 | class MockData { 20 | /** 21 | * @method clearMockData 22 | * 23 | * Clear out Mock Data 24 | */ 25 | async clearMockData() { 26 | await organizationService.deleteAllRows(); 27 | await settingService.deleteAllRows(); 28 | await userService.deleteAllRows(); 29 | await pointService.deleteAllRows(); 30 | await publicationService.deleteAllRows(); 31 | await caseService.deleteAllRows(); 32 | sinon.restore(); 33 | } 34 | 35 | /** 36 | * @method mockUser 37 | * 38 | * Generate Mock User 39 | */ 40 | async mockUser(options = {}) { 41 | if (!options.username) throw new Error('Username must be provided'); 42 | if (!options.organization_id) 43 | throw new Error('Organization ID must be provided'); 44 | 45 | if (!process.env.SEED_MAPS_API_KEY) { 46 | throw new Error('Populate environment variable SEED_MAPS_API_KEY'); 47 | } 48 | 49 | const params = { 50 | id: uuidv4(), 51 | idm_id: uuidv4(), 52 | organization_id: options.organization_id, 53 | username: options.username, 54 | is_admin: true, 55 | maps_api_key: process.env.SEED_MAPS_API_KEY, 56 | }; 57 | 58 | const results = await userService.create(params); 59 | if (results) { 60 | return results[0]; 61 | } 62 | throw new Error('Problem adding the organization.'); 63 | } 64 | 65 | /** 66 | * @method mockOrganization 67 | * 68 | * Generate Mock Organization 69 | */ 70 | async mockOrganization(options = {}) { 71 | if (options.id) throw new Error('ID is not needed.'); 72 | if (!options.name) throw new Error('Authority Name must be provided'); 73 | 74 | const coords = randomCoordinates({ fixed: 5 }).split(','); 75 | 76 | const org = _.extend( 77 | { 78 | reference_website_url: 'https://reference.wowza.com/', 79 | api_endpoint_url: 'https://api.wowza.com/safe_paths/', 80 | privacy_policy_url: 'https://privacy.wowza.com/safe_paths/', 81 | region_coordinates: { latitude: coords[0], longitude: coords[1] }, 82 | }, 83 | options, 84 | ); 85 | 86 | let publicOrg = {}; 87 | 88 | try { 89 | sinon.restoreObject(publicOrganizationService); 90 | } catch (error) { 91 | // no-op 92 | } 93 | 94 | sinon.stub(publicOrganizationService, 'create').callsFake(params => { 95 | publicOrg = params; 96 | return publicOrg; 97 | }); 98 | 99 | sinon 100 | .stub(publicOrganizationService, 'updateOne') 101 | .callsFake((id, params) => { 102 | if (id === publicOrg.id) { 103 | publicOrg = params; 104 | } 105 | return publicOrg; 106 | }); 107 | 108 | const results = await organizationService.createOrganization(org); 109 | 110 | if (results) { 111 | return results; 112 | } 113 | 114 | throw new Error('Problem adding the organization.'); 115 | } 116 | 117 | /** 118 | * Generate Mock Trails 119 | * 120 | * User primarily for testing volume of records. 121 | * 122 | * @method mockTrails 123 | * @param {Number} numberOfTrails 124 | * @param {Number} timeIncrementInSeconds 125 | * @param {Object} options 126 | */ 127 | async mockTrails(numberOfTrails, timeIncrementInSeconds, options = {}) { 128 | if (!numberOfTrails) throw new Error('Number of Trails must be provided'); 129 | if (!timeIncrementInSeconds) 130 | throw new Error('Info Website must be provided'); 131 | if (!options.caseId) throw new Error('Case ID must be provided'); 132 | 133 | let trails = this._generateTrailsData( 134 | numberOfTrails, 135 | timeIncrementInSeconds, 136 | options.startAt, 137 | ); 138 | let results = await pointService.insertRedactedTrailSet( 139 | trails, 140 | options.caseId, 141 | ); 142 | if (results) { 143 | return results; 144 | } 145 | throw new Error('Problem adding the trails.'); 146 | } 147 | 148 | /** 149 | * Generate Mock Trails for Load Test 150 | * 151 | * Pass in the case id and the pre generated trails. 152 | * 153 | * @method mockTrailsLoadTest 154 | * @param {String} startTime 155 | * @param {Object} options 156 | * @param {Number} options.startTime 157 | * @param {Number} options.organization_id 158 | * @param {Number} options.organization_id 159 | */ 160 | async mockTrailsLoadTest(options = {}) { 161 | if (!options.startTime) throw new Error('Start Time must be provided'); 162 | if (!options.organization_id) 163 | throw new Error('Organization ID must be provided'); 164 | if (!options.numberOfRecords) 165 | throw new Error('Number of Records must be provided'); 166 | 167 | const caseParams = { 168 | organization_id: options.organization_id, 169 | state: options.state || 'unpublished', 170 | }; 171 | let pointsCase = await this.mockCase(caseParams); 172 | if (pointsCase) { 173 | const trails = this._generateTrailsData( 174 | options.numberOfRecords, 175 | 300, 176 | options.startTime, 177 | true, 178 | ); 179 | const results = await pointService.loadTestRedactedTrails( 180 | trails, 181 | pointsCase.caseId, 182 | ); 183 | if (results) { 184 | return results; 185 | } 186 | } 187 | throw new Error('Problem adding the trails.'); 188 | } 189 | 190 | /** 191 | * Generate Mock Trails Directly from Data 192 | * 193 | * Pass in the case id and the pre generated trails. 194 | * 195 | * @method mockTrailsDirect 196 | * @param {String} startTime 197 | * @param {Object} options 198 | * @param {Number} options.caseId 199 | */ 200 | async mockTrailsDirect(options = {}) { 201 | if (!options.startTime) throw new Error('Start Time must be provided'); 202 | if (!options.organization_id) 203 | throw new Error('Organization ID must be provided'); 204 | 205 | const caseParams = { 206 | organization_id: options.organization_id, 207 | state: options.state || 'unpublished', 208 | }; 209 | let pointsCase = await this.mockCase(caseParams); 210 | if (pointsCase) { 211 | const trails = this._generateLargeGroupingOfPoints(options.startTime); 212 | const results = await pointService.insertRedactedTrailSet( 213 | trails, 214 | pointsCase.caseId, 215 | ); 216 | if (results) { 217 | return results; 218 | } 219 | } 220 | throw new Error('Problem adding the trails.'); 221 | } 222 | 223 | /** 224 | * @method mockPublication 225 | * 226 | * Generate Mock Publication 227 | */ 228 | async mockPublication(options = {}) { 229 | if (!options.organization_id) 230 | throw new Error('Organization ID must be provided'); 231 | if (!options.start_date) throw new Error('Start Date must be provided'); 232 | if (!options.end_date) throw new Error('End Date must be provided'); 233 | 234 | let params = { 235 | publish_date: new Date(), 236 | }; 237 | 238 | const results = await publicationService.insert(_.extend(params, options)); 239 | if (results) { 240 | return results; 241 | } 242 | throw new Error('Problem adding the publication.'); 243 | } 244 | 245 | async mockCase(options = {}) { 246 | if (!options.organization_id) 247 | throw new Error('Organization ID must be provided.'); 248 | if (!options.state) throw new Error('State must be provided.'); 249 | 250 | const params = { 251 | state: options.state, 252 | organization_id: options.organization_id, 253 | external_id: options.external_id, 254 | expires_at: options.expires_at, 255 | }; 256 | 257 | const organization = await organizationService.fetchById( 258 | options.organization_id, 259 | ); 260 | if (organization) { 261 | if (!params.expires_at) 262 | params.expires_at = moment() 263 | .startOf('day') 264 | .add(organization.daysToRetainRecords, 'days') 265 | .format(); 266 | const result = await caseService.createCase(params); 267 | if (result) { 268 | return result; 269 | } 270 | } 271 | 272 | throw new Error('Problem adding the case.'); 273 | } 274 | 275 | async mockCaseAndTrails(options = {}) { 276 | if (!options.organization_id) 277 | throw new Error('Organization ID must be provided.'); 278 | if (!options.number_of_trails) 279 | throw new Error('Number of trails is invalid.'); 280 | if (!options.seconds_apart) throw new Error('Seconds Apart is invalid.'); 281 | if (!options.state) throw new Error('State is invalid.'); 282 | 283 | let caseParams = { 284 | organization_id: options.organization_id, 285 | state: options.state, 286 | expires_at: options.expires_at, 287 | }; 288 | if (options.publishedOn) caseParams.state = 'published'; 289 | 290 | let newCase = await this.mockCase(caseParams); 291 | newCase.points = []; 292 | 293 | // Add Points 294 | let trailsParams = { 295 | caseId: newCase.caseId, 296 | }; 297 | if (options.publishedOn) trailsParams.startAt = options.publishedOn; 298 | const points = await this.mockTrails( 299 | options.number_of_trails, 300 | options.seconds_apart, 301 | trailsParams, 302 | ); 303 | if (points) { 304 | newCase.points = newCase.points.concat(points); 305 | } 306 | 307 | if (options.publishedOn) { 308 | const publicationParams = { 309 | organization_id: options.organization_id, 310 | publish_date: options.publishedOn, 311 | }; 312 | const publication = await publicationService.insert(publicationParams); 313 | await caseService.updateCasePublicationId( 314 | [newCase.caseId], 315 | publication.id, 316 | ); 317 | } 318 | 319 | return newCase; 320 | } 321 | 322 | /** 323 | * Generate a mock access code 324 | * 325 | * @method mockAccessCode 326 | */ 327 | async mockAccessCode() { 328 | const mockCode = { 329 | id: 1, 330 | value: await accessCodeService.generateValue(), 331 | valid: true, 332 | }; 333 | 334 | try { 335 | sinon.restoreObject(accessCodeService); 336 | } catch (error) { 337 | // no-opconsole.log(error) 338 | } 339 | 340 | sinon.stub(accessCodeService, 'create').returns(mockCode); 341 | 342 | sinon.stub(accessCodeService, 'find').callsFake(query => { 343 | if ( 344 | query && 345 | (query.id === mockCode.id || query.value === mockCode.value) 346 | ) { 347 | return mockCode; 348 | } 349 | return null; 350 | }); 351 | 352 | return await accessCodeService.create(); 353 | } 354 | 355 | /** 356 | * Generates mock upload points 357 | * 358 | * @method mockUploadPoints 359 | * @param {Number} num 360 | * @param {Number} timeIncrementInSeconds 361 | * @param {Object} options 362 | */ 363 | async mockUploadPoints(accessCode, num) { 364 | if (!accessCode || !accessCode.id) 365 | throw new Error('Access code must be provided'); 366 | 367 | const accessCodeId = accessCode.id; 368 | let points = await this._generateUploadedPoints(accessCodeId, num); 369 | 370 | try { 371 | sinon.restoreObject(uploadService); 372 | } catch (error) { 373 | // no-op 374 | } 375 | 376 | sinon.stub(uploadService, 'create').returns(points); 377 | 378 | sinon.stub(uploadService, 'fetchPoints').callsFake(accessCode => { 379 | if (accessCode && accessCode.id === accessCodeId) { 380 | return points; 381 | } 382 | return []; 383 | }); 384 | 385 | sinon.stub(uploadService, 'deletePoints').callsFake(accessCode => { 386 | if (accessCode && accessCode.id === accessCodeId) { 387 | points = []; 388 | } 389 | }); 390 | 391 | return await uploadService.create(points); 392 | } 393 | 394 | // private 395 | 396 | _getRandomCoordinates() { 397 | const coords = randomCoordinates({ fixed: 5 }).split(','); 398 | return { 399 | longitude: parseFloat(coords[1]), 400 | latitude: parseFloat(coords[0]), 401 | }; 402 | } 403 | 404 | _generateLargeGroupingOfPoints(startTime) { 405 | let final = []; 406 | 407 | const groupOne = this._generateGroupedTrailsData( 408 | this._getRandomCoordinates(), 409 | startTime, 410 | 25, 411 | ); 412 | final = final.concat(groupOne); 413 | 414 | // Start 5 minutes after the last trail point and add 100 random points in 5 min increments 415 | const randomTrails = this._generateTrailsData( 416 | 100, 417 | 300, 418 | groupOne[4].time + 300000, 419 | false, 420 | ); 421 | final = final.concat(randomTrails); 422 | 423 | // Create another grouping for 45 min 424 | const groupTwo = this._generateGroupedTrailsData( 425 | this._getRandomCoordinates(), 426 | randomTrails[99].time + 300000, 427 | 45, 428 | ); 429 | final = final.concat(groupTwo); 430 | 431 | // Start 5 minutes after the last trail point and add 250 random points in 5 min increments 432 | const randomTrailsTwo = this._generateTrailsData( 433 | 250, 434 | 300, 435 | groupTwo[4].time + 300000, 436 | false, 437 | ); 438 | final = final.concat(randomTrailsTwo); 439 | 440 | // Create another grouping for 15 min 441 | const groupThree = this._generateGroupedTrailsData( 442 | this._getRandomCoordinates(), 443 | randomTrailsTwo[99].time + 300000, 444 | 15, 445 | ); 446 | final = final.concat(groupThree); 447 | 448 | return final; 449 | } 450 | 451 | _generateGroupedTrailsData(coordinates, startTime, duration) { 452 | const standardIncrement = 5; 453 | const numberOfTrails = duration / standardIncrement; 454 | let coordTime = startTime; 455 | return Array(numberOfTrails) 456 | .fill('') 457 | .map(() => { 458 | coordTime = coordTime + standardIncrement * 60 * 1000; 459 | return { 460 | longitude: coordinates.longitude, 461 | latitude: coordinates.latitude, 462 | time: coordTime, 463 | }; 464 | }); 465 | } 466 | 467 | _generateTrailsData( 468 | numberOfTrails, 469 | timeIncrementInSeconds, 470 | startAt = new Date().getTime(), 471 | decrementTime = true, 472 | ) { 473 | let coordTime = Math.floor(startAt / 1000); 474 | return Array(numberOfTrails) 475 | .fill('') 476 | .map(() => { 477 | if (decrementTime) { 478 | coordTime = coordTime - timeIncrementInSeconds; 479 | } else { 480 | coordTime = coordTime + timeIncrementInSeconds; 481 | } 482 | const coords = randomCoordinates({ fixed: 5 }).split(','); 483 | return { 484 | longitude: parseFloat(coords[1]), 485 | latitude: parseFloat(coords[0]), 486 | time: coordTime, 487 | }; 488 | }); 489 | } 490 | 491 | /* eslint-disable */ 492 | async _generateUploadedPoints(accessCodeId, num) { 493 | const uploadId = uuidv4(); 494 | 495 | let final = []; 496 | let i; 497 | for (i of Array(num).fill('')) { 498 | const coords = randomCoordinates({ fixed: 5 }).split(','); 499 | const dbData = await pointService.fetchTestHash( 500 | parseFloat(coords[1]), 501 | parseFloat(coords[0]), 502 | ); 503 | const entry = { 504 | access_code_id: accessCodeId, 505 | upload_id: uploadId, 506 | coordinates: dbData.point, 507 | time: dbData.time, 508 | }; 509 | final.push(entry); 510 | } 511 | return final; 512 | } 513 | /* eslint-enable */ 514 | } 515 | 516 | module.exports = new MockData(); 517 | -------------------------------------------------------------------------------- /openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.0 2 | info: 3 | title: SafePlaces Backend API 4 | contact: {} 5 | version: '1.0' 6 | paths: 7 | /login: 8 | post: 9 | summary: Returns a JWT to be used in subsequent API requests 10 | operationId: Login 11 | parameters: [] 12 | requestBody: 13 | content: 14 | application/json: 15 | schema: 16 | $ref: '#/components/schemas/LoginRequest' 17 | example: 18 | username: spladmin 19 | password: password 20 | required: true 21 | responses: 22 | '200': 23 | description: '' 24 | headers: {} 25 | content: 26 | application/json: 27 | schema: 28 | $ref: '#/components/schemas/LoginResponse' 29 | 30 | deprecated: false 31 | security: [] 32 | 33 | /access-code: 34 | post: 35 | summary: Returns an access code to be used when uploading points of concern data to ingest service 36 | operationId: AccessCode 37 | parameters: [] 38 | responses: 39 | '200': 40 | description: '' 41 | headers: {} 42 | content: 43 | application/json: 44 | schema: 45 | $ref: '#/components/schemas/AccessCodeResponse' 46 | deprecated: false 47 | 48 | /organization: 49 | get: 50 | summary: Returns information about the logged in user's organization 51 | operationId: Organization 52 | parameters: [] 53 | responses: 54 | '200': 55 | description: '' 56 | headers: {} 57 | content: 58 | application/json: 59 | schema: 60 | $ref: '#/components/schemas/OrganizationResponse' 61 | 62 | deprecated: true 63 | 64 | /organization/configuration: 65 | put: 66 | summary: Returns information about the logged in user's organization 67 | operationId: OrganizationConfiguration 68 | parameters: [] 69 | requestBody: 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/OrganizationConfigurationRequest' 74 | required: true 75 | responses: 76 | '200': 77 | description: '' 78 | headers: {} 79 | content: 80 | application/json: 81 | schema: 82 | $ref: '#/components/schemas/OrganizationConfigurationResponse' 83 | deprecated: false 84 | get: 85 | summary: Organization Configuration 86 | operationId: GetOrganizationConfiguration 87 | parameters: [] 88 | responses: 89 | '200': 90 | description: '' 91 | headers: {} 92 | content: 93 | application/json: 94 | schema: 95 | $ref: '#/components/schemas/OrganizationConfigurationResponse' 96 | deprecated: false 97 | 98 | /organization/cases: 99 | get: 100 | summary: Returns all cases associated with the organization of the logged in user 101 | operationId: OrganizationCases 102 | parameters: [] 103 | responses: 104 | '200': 105 | description: '' 106 | headers: {} 107 | content: 108 | application/json: 109 | schema: 110 | $ref: '#/components/schemas/OrganizationCasesResponse' 111 | deprecated: false 112 | 113 | /organization/case: 114 | post: 115 | summary: Create a new case 116 | operationId: CreateCase 117 | parameters: [] 118 | responses: 119 | '200': 120 | description: '' 121 | headers: {} 122 | content: 123 | application/json: 124 | schema: 125 | $ref: '#/components/schemas/CreateCaseResponse' 126 | deprecated: false 127 | 128 | /case: 129 | put: 130 | summary: Update an existing case 131 | operationId: UpdateCase 132 | parameters: [] 133 | requestBody: 134 | content: 135 | application/json: 136 | schema: 137 | $ref: '#/components/schemas/UpdateCaseRequest' 138 | example: 139 | caseId: 1 140 | externalId: an_id 141 | required: true 142 | responses: 143 | '200': 144 | description: '' 145 | headers: {} 146 | content: 147 | application/json: 148 | schema: 149 | $ref: '#/components/schemas/UpdateCaseResponse' 150 | deprecated: false 151 | 152 | /case/delete: 153 | post: 154 | summary: Delete a case 155 | operationId: DeleteCase 156 | parameters: [] 157 | requestBody: 158 | content: 159 | application/json: 160 | schema: 161 | $ref: '#/components/schemas/DeleteCaseRequest' 162 | example: 163 | caseId: 2 164 | required: true 165 | responses: 166 | '200': 167 | description: 'Case was successfully deleted' 168 | headers: {} 169 | deprecated: false 170 | 171 | /case/consent-to-publishing: 172 | post: 173 | summary: Record user's consent to the publishing of their data 174 | operationId: ConsentToPublishing 175 | parameters: [] 176 | requestBody: 177 | content: 178 | application/json: 179 | schema: 180 | $ref: '#/components/schemas/ConsentToPublishingRequest' 181 | example: 182 | caseId: 1 183 | required: true 184 | responses: 185 | '200': 186 | description: '' 187 | headers: {} 188 | content: 189 | application/json: 190 | schema: 191 | $ref: '#/components/schemas/UpdateCaseResponse' 192 | deprecated: false 193 | 194 | /case/stage: 195 | post: 196 | summary: Mark a case as ready for review before publishing 197 | operationId: StageCase 198 | parameters: [] 199 | requestBody: 200 | content: 201 | application/json: 202 | schema: 203 | $ref: '#/components/schemas/StageCaseRequest' 204 | example: 205 | caseId: 1 206 | required: true 207 | responses: 208 | '200': 209 | description: '' 210 | headers: {} 211 | content: 212 | application/json: 213 | schema: 214 | $ref: '#/components/schemas/StageCaseResponse' 215 | deprecated: false 216 | 217 | /cases/publish: 218 | post: 219 | summary: Select a set a cases whose points of concern will be added to the publicly accessible published data set 220 | operationId: PublishCase 221 | parameters: [] 222 | requestBody: 223 | content: 224 | application/json: 225 | schema: 226 | $ref: '#/components/schemas/PublishCaseRequest' 227 | example: 228 | caseIds: 229 | - 1 230 | - 2 231 | required: true 232 | responses: 233 | '200': 234 | description: 'Cases were successfully published' 235 | headers: {} 236 | deprecated: false 237 | 238 | components: 239 | schemas: 240 | LoginRequest: 241 | title: LoginRequest 242 | required: 243 | - username 244 | - password 245 | type: object 246 | properties: 247 | username: 248 | type: string 249 | password: 250 | type: string 251 | example: 252 | username: spladmin 253 | password: password 254 | 255 | LoginResponse: 256 | type: object 257 | properties: 258 | token: 259 | type: string 260 | example: 261 | token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzcGxhZG1pbiIsImlhdCI6MTU5Mjk5NzgzNywiZXhwIjoxNTkzMDMzODM3fQ.uJ2M8qzRT8OaLQVqV8vg5kAy8ylRD10QgeK2z-uRZME" 262 | 263 | OrganizationResponse: 264 | title: OrganizationResponse 265 | type: object 266 | properties: 267 | id: 268 | type: integer 269 | externalId: 270 | type: string 271 | name: 272 | type: string 273 | completedOnboarding: 274 | type: boolean 275 | example: 276 | id: 1 277 | name: Health Authority 278 | externalId: 1eb7c9ac-e417-4845-a7e3-a74db447ecc7 279 | completedOnboarding: true 280 | 281 | OrganizationConfigurationRequest: 282 | title: OrganizationConfigurationRequest 283 | required: 284 | - name 285 | - notificationThresholdPercent 286 | - notificationThresholdTimeline 287 | - daysToRetainRecords 288 | - regionCoordinates 289 | - apiEndpointUrl 290 | - referenceWebsiteUrl 291 | - infoWebsiteUrl 292 | - completedOnboarding 293 | - privacyPolicyUrl 294 | type: object 295 | properties: 296 | name: 297 | type: string 298 | notificationThresholdPercent: 299 | type: integer 300 | format: int32 301 | notificationThresholdTimeline: 302 | type: integer 303 | format: int32 304 | daysToRetainRecords: 305 | type: integer 306 | format: int32 307 | regionCoordinates: 308 | $ref: '#/components/schemas/RegionCoordinates' 309 | apiEndpointUrl: 310 | type: string 311 | referenceWebsiteUrl: 312 | type: string 313 | infoWebsiteUrl: 314 | type: string 315 | completedOnboarding: 316 | type: boolean 317 | privacyPolicyUrl: 318 | type: string 319 | example: 320 | name: Health Authority 321 | notificationThresholdPercent: 6 322 | notificationThresholdTimeline: 0 323 | daysToRetainRecords: 30 324 | regionCoordinates: 325 | ne: 326 | latitude: 21.312764055951195 327 | longitude: -21.45445121262883 328 | sw: 329 | latitude: 21.766025040122642 330 | longitude: -21.49442923997258 331 | apiEndpointUrl: 'https://s3.aws.com/bucket_name' 332 | referenceWebsiteUrl: 'http://nbc.gov' 333 | infoWebsiteUrl: 'http://nbc.gov' 334 | completedOnboarding: true 335 | privacyPolicyUrl: http://test.com 336 | 337 | OrganizationConfigurationResponse: 338 | title: OrganizationConfigurationResponse 339 | type: object 340 | properties: 341 | name: 342 | type: string 343 | notificationThresholdPercent: 344 | type: integer 345 | format: int32 346 | notificationThresholdTimeline: 347 | type: integer 348 | format: int32 349 | daysToRetainRecords: 350 | type: integer 351 | format: int32 352 | regionCoordinates: 353 | $ref: '#/components/schemas/RegionCoordinates' 354 | apiEndpointUrl: 355 | type: string 356 | referenceWebsiteUrl: 357 | type: string 358 | infoWebsiteUrl: 359 | type: string 360 | completedOnboarding: 361 | type: boolean 362 | privacyPolicyUrl: 363 | type: string 364 | example: 365 | name: Health Authority 366 | notificationThresholdPercent: 6 367 | notificationThresholdTimeline: 0 368 | daysToRetainRecords: 30 369 | regionCoordinates: 370 | ne: 371 | latitude: 21.312764055951195 372 | longitude: -21.45445121262883 373 | sw: 374 | latitude: 21.766025040122642 375 | longitude: -21.49442923997258 376 | apiEndpointUrl: 'https://s3.aws.com/bucket_name' 377 | referenceWebsiteUrl: 'http://nbc.gov' 378 | infoWebsiteUrl: 'http://nbc.gov' 379 | completedOnboarding: true 380 | privacyPolicyUrl: 'http://test.com' 381 | externalId: 'a30ed5ec-b639-11ea-b3de-0242ac130004' 382 | 383 | OrganizationCasesResponse: 384 | title: OrganizationCasesResponse 385 | type: array 386 | items: 387 | type: object 388 | example: 389 | cases: 390 | - caseId: 1 391 | state: staging 392 | updatedAt: "2020-06-24T11:26:10.416Z" 393 | stagedAt: "2020-07-24T04:00:00.000Z" 394 | externalId: "2fee5ac6-e11d-47e4-a89d-8a5a80201710" 395 | contactTracerId: "a88309ca-26cd-4d2b-8923-af0779e423a3" 396 | - caseId: 2 397 | state: unpublished 398 | updatedAt: "2020-06-24T11:26:10.416Z" 399 | stagedAt: null 400 | externalId: "5add5ac6-e11d-47e4-a89d-8a5a8020123" 401 | contactTracerId: "a88309ca-26cd-4d2b-8923-af0779e423a3" 402 | 403 | RegionCoordinates: 404 | title: RegionCoordinates 405 | required: 406 | - ne 407 | - sw 408 | type: object 409 | properties: 410 | ne: 411 | $ref: '#/components/schemas/Ne' 412 | sw: 413 | $ref: '#/components/schemas/Sw' 414 | example: 415 | ne: 416 | latitude: 21.312764055951195 417 | longitude: -21.45445121262883 418 | sw: 419 | latitude: 21.766025040122642 420 | longitude: -21.49442923997258 421 | 422 | Ne: 423 | title: Ne 424 | required: 425 | - latitude 426 | - longitude 427 | type: object 428 | properties: 429 | latitude: 430 | type: number 431 | longitude: 432 | type: number 433 | example: 434 | latitude: 21.312764055951195 435 | longitude: -21.45445121262883 436 | 437 | Sw: 438 | title: Sw 439 | required: 440 | - latitude 441 | - longitude 442 | type: object 443 | properties: 444 | latitude: 445 | type: number 446 | longitude: 447 | type: number 448 | example: 449 | latitude: 21.766025040122642 450 | longitude: -21.49442923997258 451 | 452 | DeleteCaseRequest: 453 | title: DeleteCaseRequest 454 | required: 455 | - caseId 456 | type: object 457 | properties: 458 | caseId: 459 | type: integer 460 | format: int32 461 | example: 462 | caseId: 2 463 | 464 | AccessCodeResponse: 465 | title: AccessCodeResponse 466 | type: object 467 | properties: 468 | accessCode: 469 | type: string 470 | example: 471 | accessCode: "123456" 472 | 473 | CreateCaseResponse: 474 | title: CreateCaseResponse 475 | type: object 476 | example: 477 | caseId: 1 478 | state: unpublished 479 | updatedAt: "2020-06-24T11:26:10.416Z" 480 | stagedAt: null 481 | externalId: "2fee5ac6-e11d-47e4-a89d-8a5a80201710" 482 | contactTracerId: "a88309ca-26cd-4d2b-8923-af0779e423a3" 483 | 484 | UpdateCaseRequest: 485 | title: UpdateCaseRequest 486 | required: 487 | - caseId 488 | - externalId 489 | type: object 490 | properties: 491 | caseId: 492 | type: integer 493 | format: int32 494 | externalId: 495 | type: string 496 | example: 497 | caseId: 1 498 | externalId: an_id 499 | 500 | UpdateCaseResponse: 501 | title: UpdateCaseResponse 502 | type: object 503 | example: 504 | caseId: 1 505 | state: unpublished 506 | updatedAt: "2020-06-24T11:26:10.416Z" 507 | stagedAt: null 508 | externalId: an_id 509 | contactTracerId: "a88309ca-26cd-4d2b-8923-af0779e423a3" 510 | 511 | ConsentToPublishingRequest: 512 | title: ConsentToPublishingRequest 513 | required: 514 | - caseId 515 | type: object 516 | properties: 517 | caseId: 518 | type: integer 519 | format: int32 520 | example: 521 | caseId: 1 522 | 523 | StageCaseRequest: 524 | title: StageCaseRequest 525 | required: 526 | - caseId 527 | type: object 528 | properties: 529 | caseId: 530 | type: integer 531 | format: int32 532 | example: 533 | caseId: 1 534 | 535 | StageCaseResponse: 536 | title: UpdateCaseResponse 537 | type: object 538 | example: 539 | caseId: 1 540 | state: staging 541 | updatedAt: "2020-06-24T11:26:10.416Z" 542 | stagedAt: "2020-06-24T11:26:10.416Z" 543 | externalId: "2fee5ac6-e11d-47e4-a89d-8a5a80201710" 544 | contactTracerId: "a88309ca-26cd-4d2b-8923-af0779e423a3" 545 | 546 | PublishCaseRequest: 547 | title: PublishCaseRequest 548 | required: 549 | - caseIds 550 | type: object 551 | properties: 552 | caseIds: 553 | type: array 554 | items: 555 | type: integer 556 | format: int32 557 | example: 558 | caseIds: 559 | - 1 560 | - 2 561 | 562 | 563 | securitySchemes: 564 | httpBearer: 565 | type: http 566 | scheme: bearer 567 | bearerFormat: JWT 568 | security: 569 | - httpBearer: [] 570 | -------------------------------------------------------------------------------- /test/integration/case.test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = 'test'; 2 | process.env.DATABASE_URL = 3 | process.env.DATABASE_URL || 'postgres://localhost/safeplaces_test'; 4 | 5 | const { caseService, pointService } = require('../../app/lib/db'); 6 | const _ = require('lodash'); 7 | const moment = require('moment'); 8 | const chai = require('chai'); 9 | const should = chai.should(); // eslint-disable-line 10 | const chaiHttp = require('chai-http'); 11 | 12 | const mockData = require('../lib/mockData'); 13 | const mockAuth = require('../lib/mockAuth'); 14 | 15 | const app = require('../../app'); 16 | const server = app.getTestingServer(); 17 | 18 | const type = process.env.PUBLISH_STORAGE_TYPE || 'local'; 19 | 20 | chai.use(chaiHttp); 21 | 22 | let currentOrg, currentCase, token, ctToken; 23 | 24 | describe('Case', () => { 25 | before(async () => { 26 | await mockData.clearMockData(); 27 | 28 | let orgParams = { 29 | name: 'My Example Organization', 30 | info_website_url: 'http://sample.com', 31 | }; 32 | currentOrg = await mockData.mockOrganization(orgParams); 33 | 34 | let newUserParams = { 35 | username: 'myAwesomeUser', 36 | organization_id: currentOrg.id, 37 | }; 38 | const user = await mockData.mockUser(newUserParams); 39 | token = mockAuth.getAccessToken(user.idm_id, 'admin'); 40 | ctToken = mockAuth.getAccessToken(user.idm_id, 'contact_tracer'); 41 | }); 42 | 43 | describe('consent to publishing case', () => { 44 | before(async () => { 45 | await caseService.deleteAllRows(); 46 | 47 | const caseParams = { 48 | organization_id: currentOrg.id, 49 | state: 'unpublished', 50 | }; 51 | currentCase = await mockData.mockCase(caseParams); 52 | }); 53 | 54 | it('returns the updated case', async () => { 55 | const requestParams = { 56 | caseId: currentCase.caseId, 57 | }; 58 | 59 | const result = await chai 60 | .request(server) 61 | .post(`/case/consent-to-publishing`) 62 | .set('Cookie', `access_token=${token}`) 63 | .set('X-Requested-With', 'XMLHttpRequest') 64 | .set('content-type', 'application/json') 65 | .send(requestParams); 66 | 67 | result.error.should.be.false; 68 | result.should.have.status(200); 69 | result.body.should.be.a('object'); 70 | result.body.should.have.property('case'); 71 | result.body.case.should.be.a('object'); 72 | result.body.case.should.have.property('caseId'); 73 | result.body.case.should.have.property('externalId'); 74 | result.body.case.should.have.property('contactTracerId'); 75 | result.body.case.should.have.property('state'); 76 | result.body.case.should.have.property('updatedAt'); 77 | result.body.case.should.have.property('expiresAt'); 78 | result.body.case.caseId.should.equal(currentCase.caseId); 79 | }); 80 | }); 81 | 82 | describe('move a case to staging', () => { 83 | before(async () => { 84 | await caseService.deleteAllRows(); 85 | 86 | const caseParams = { 87 | organization_id: currentOrg.id, 88 | state: 'published', 89 | }; 90 | currentCase = await mockData.mockCase(caseParams); 91 | }); 92 | 93 | it('return the updated case', async () => { 94 | const newParams = { 95 | caseId: currentCase.caseId, 96 | }; 97 | 98 | const results = await chai 99 | .request(server) 100 | .post(`/case/stage`) 101 | .set('Cookie', `access_token=${token}`) 102 | .set('X-Requested-With', 'XMLHttpRequest') 103 | .set('content-type', 'application/json') 104 | .send(newParams); 105 | 106 | results.error.should.be.false; 107 | results.should.have.status(200); 108 | results.body.should.be.a('object'); 109 | results.body.should.have.property('case'); 110 | results.body.case.should.be.a('object'); 111 | results.body.case.should.have.property('caseId'); 112 | results.body.case.should.have.property('contactTracerId'); 113 | results.body.case.should.have.property('state'); 114 | results.body.case.should.have.property('stagedAt'); 115 | results.body.case.should.have.property('updatedAt'); 116 | results.body.case.should.have.property('expiresAt'); 117 | results.body.case.caseId.should.equal(currentCase.caseId); 118 | results.body.case.state.should.equal('staging'); 119 | }); 120 | }); 121 | 122 | describe('publish a case(s)', () => { 123 | let caseOne, caseTwo, caseThree; 124 | 125 | beforeEach(async () => { 126 | await caseService.deleteAllRows(); 127 | await pointService.deleteAllRows(); 128 | 129 | let params = { 130 | organization_id: currentOrg.id, 131 | number_of_trails: 10, 132 | seconds_apart: 1800, 133 | state: 'staging', 134 | }; 135 | 136 | caseOne = await mockData.mockCaseAndTrails(params); 137 | caseTwo = await mockData.mockCaseAndTrails(params); 138 | caseThree = await mockData.mockCaseAndTrails(params); 139 | }); 140 | 141 | it(`returns multiple published cases (${type})`, async () => { 142 | const newParams = { 143 | caseIds: [caseOne.caseId, caseTwo.caseId, caseThree.caseId], 144 | }; 145 | 146 | const results = await chai 147 | .request(server) 148 | .post(`/cases/publish?type=${type}`) 149 | .set('Cookie', `access_token=${token}`) 150 | .set('X-Requested-With', 'XMLHttpRequest') 151 | .set('content-type', 'application/json') 152 | .send(newParams); 153 | 154 | results.error.should.be.false; 155 | results.should.have.status(200); 156 | results.body.should.be.a('object'); 157 | results.body.should.have.property('cases'); 158 | results.body.cases.should.be.a('array'); 159 | 160 | results.body.cases.forEach(c => { 161 | c.should.have.property('caseId'); 162 | c.should.have.property('externalId'); 163 | c.should.have.property('contactTracerId'); 164 | c.state.should.be.equal('published'); 165 | c.should.have.property('state'); 166 | c.should.have.property('updatedAt'); 167 | c.should.have.property('expiresAt'); 168 | }); 169 | }); 170 | 171 | it('returns test json to validate contents of file', async () => { 172 | const newParams = { 173 | caseIds: [caseOne.caseId, caseTwo.caseId, caseThree.caseId], 174 | }; 175 | 176 | const results = await chai 177 | .request(server) 178 | .post(`/cases/publish?type=json`) 179 | .set('Cookie', `access_token=${token}`) 180 | .set('X-Requested-With', 'XMLHttpRequest') 181 | .set('content-type', 'application/json') 182 | .send(newParams); 183 | let pageEndpoint = `${currentOrg.apiEndpointUrl}[PAGE].json`; 184 | 185 | results.error.should.be.false; 186 | results.should.have.status(200); 187 | results.body.should.be.a('object'); 188 | 189 | results.body.files.should.be.a('array'); 190 | 191 | const firstChunk = results.body.files.shift(); 192 | firstChunk.should.be.a('object'); 193 | 194 | firstChunk.should.have.property('name'); 195 | firstChunk.should.have.property('notification_threshold_percent'); 196 | firstChunk.should.have.property('notification_threshold_timeframe'); 197 | firstChunk.should.have.property('concern_point_hashes'); 198 | firstChunk.should.have.property('info_website_url'); 199 | firstChunk.should.have.property('publish_date_utc'); 200 | if (process.env.HASHING_TEST) { 201 | firstChunk.should.have.property('points_for_test'); 202 | } 203 | firstChunk.name.should.equal(currentOrg.name); 204 | firstChunk.info_website_url.should.equal(currentOrg.infoWebsiteUrl); 205 | 206 | firstChunk.concern_point_hashes.should.be.a('array'); 207 | firstChunk.concern_point_hashes.length.should.equal(30); 208 | firstChunk.concern_point_hashes.forEach(point => { 209 | point.should.be.a('string'); 210 | }); 211 | 212 | const firstCursor = results.body.cursor.pages.shift(); 213 | firstCursor.should.be.a('object'); 214 | firstCursor.should.have.property('id'); 215 | firstCursor.id.should.be.a('string'); 216 | firstCursor.should.have.property('startTimestamp'); 217 | firstCursor.startTimestamp.should.be.a('number'); 218 | firstCursor.should.have.property('endTimestamp'); 219 | firstCursor.endTimestamp.should.be.a('number'); 220 | firstCursor.should.have.property('filename'); 221 | firstCursor.filename.should.be.a('string'); 222 | firstCursor.filename.should.equal( 223 | pageEndpoint.replace( 224 | '[PAGE]', 225 | `${firstCursor.startTimestamp}_${firstCursor.endTimestamp}`, 226 | ), 227 | ); 228 | }); 229 | }); 230 | 231 | describe('publishes cases that generate multiple files', () => { 232 | let newCase; 233 | 234 | beforeEach(async () => { 235 | await caseService.deleteAllRows(); 236 | await pointService.deleteAllRows(); 237 | 238 | let params = { 239 | organization_id: currentOrg.id, 240 | number_of_trails: 10, 241 | seconds_apart: 300, 242 | state: 'staging', 243 | }; 244 | 245 | // Create two cases that have been published. 246 | await mockData.mockCaseAndTrails( 247 | _.extend(params, { 248 | publishedOn: new Date(new Date().getTime() - 86400 * (5 * 1000)), 249 | }), 250 | ); // Published 5 days ago 251 | await mockData.mockCaseAndTrails( 252 | _.extend(params, { 253 | publishedOn: new Date(new Date().getTime() - 86400 * (2 * 1000)), 254 | }), 255 | ); // Published 2 days ago 256 | 257 | // Create third case that will be published on call. 258 | newCase = await mockData.mockCaseAndTrails( 259 | _.extend(params, { publishedOn: null }), 260 | ); 261 | }); 262 | 263 | it('returns test json to validate contents of file', async () => { 264 | const newParams = { 265 | caseIds: [newCase.caseId], 266 | }; 267 | 268 | const results = await chai 269 | .request(server) 270 | .post(`/cases/publish?type=json`) 271 | .set('Cookie', `access_token=${token}`) 272 | .set('X-Requested-With', 'XMLHttpRequest') 273 | .set('content-type', 'application/json') 274 | .send(newParams); 275 | 276 | results.error.should.be.false; 277 | results.should.have.status(200); 278 | results.body.should.be.a('object'); 279 | 280 | results.body.cursor.should.be.a('object'); 281 | results.body.cursor.pages.should.be.a('array'); 282 | results.body.cursor.pages.length.should.equal(3); 283 | results.body.cursor.pages[0].checksum.should.be.a('string'); 284 | results.body.files.should.be.a('array'); 285 | results.body.files.length.should.equal(3); 286 | }); 287 | }); 288 | 289 | describe('honors expires at on previously published case', function () { 290 | this.timeout(5000); 291 | 292 | let caseTwo, caseThree; 293 | 294 | beforeEach(async () => { 295 | await caseService.deleteAllRows(); 296 | await pointService.deleteAllRows(); 297 | 298 | let params = { 299 | organization_id: currentOrg.id, 300 | number_of_trails: 10, 301 | seconds_apart: 1800, 302 | }; 303 | 304 | let invalidDate = moment().startOf('day').subtract(60, 'days').format(); // Two months ago 305 | 306 | await mockData.mockCaseAndTrails( 307 | _.extend(params, { state: 'published', expires_at: invalidDate }), 308 | ); 309 | caseTwo = await mockData.mockCaseAndTrails( 310 | _.extend(params, { state: 'staging', expires_at: null }), 311 | ); 312 | caseThree = await mockData.mockCaseAndTrails( 313 | _.extend(params, { state: 'staging', expires_at: null }), 314 | ); 315 | }); 316 | 317 | it(`returns only 2 of the 3 cases entered (${type})`, async () => { 318 | const newParams = { 319 | caseIds: [caseTwo.caseId, caseThree.caseId], 320 | }; 321 | 322 | const results = await chai 323 | .request(server) 324 | .post(`/cases/publish?type=${type}`) 325 | .set('Cookie', `access_token=${token}`) 326 | .set('X-Requested-With', 'XMLHttpRequest') 327 | .set('content-type', 'application/json') 328 | .send(newParams); 329 | 330 | results.error.should.be.false; 331 | results.should.have.status(200); 332 | results.body.should.be.a('object'); 333 | results.body.should.have.property('cases'); 334 | results.body.cases.should.be.a('array'); 335 | results.body.cases.length.should.be.equal(2); 336 | }); 337 | 338 | it('returns only points from 2 of the 3 cases entered', async () => { 339 | const newParams = { 340 | caseIds: [caseTwo.caseId, caseThree.caseId], 341 | }; 342 | 343 | const results = await chai 344 | .request(server) 345 | .post(`/cases/publish?type=json`) 346 | .set('Cookie', `access_token=${token}`) 347 | .set('X-Requested-With', 'XMLHttpRequest') 348 | .set('content-type', 'application/json') 349 | .send(newParams); 350 | 351 | results.error.should.be.false; 352 | results.should.have.status(200); 353 | results.body.should.be.a('object'); 354 | results.body.files[0].concern_point_hashes.length.should.equal(20); 355 | }); 356 | }); 357 | 358 | describe('fails because one of the cases is set to unpublished', () => { 359 | let caseOneInvalid, caseTwo, caseThree; 360 | 361 | beforeEach(async () => { 362 | await caseService.deleteAllRows(); 363 | await pointService.deleteAllRows(); 364 | 365 | let params = { 366 | organization_id: currentOrg.id, 367 | number_of_trails: 10, 368 | seconds_apart: 1800, 369 | state: 'staging', 370 | }; 371 | 372 | caseOneInvalid = await mockData.mockCaseAndTrails( 373 | _.extend(params, { state: 'unpublished' }), 374 | ); 375 | caseTwo = await mockData.mockCaseAndTrails(params); 376 | caseThree = await mockData.mockCaseAndTrails(params); 377 | }); 378 | 379 | it('returns a 500', async () => { 380 | const newParams = { 381 | caseIds: [caseOneInvalid.id, caseTwo.id, caseThree.id], 382 | }; 383 | 384 | const results = await chai 385 | .request(server) 386 | .post(`/cases/publish`) 387 | .set('Cookie', `access_token=${token}`) 388 | .set('X-Requested-With', 'XMLHttpRequest') 389 | .set('content-type', 'application/json') 390 | .send(newParams); 391 | 392 | results.error.should.not.be.false; 393 | results.should.have.status(500); 394 | }); 395 | }); 396 | 397 | it('fails because the user is a contact tracer', async () => { 398 | const results = await chai 399 | .request(server) 400 | .post(`/cases/publish`) 401 | .set('Cookie', `access_token=${ctToken}`) 402 | .set('X-Requested-With', 'XMLHttpRequest') 403 | .set('content-type', 'application/json') 404 | .send({}); 405 | 406 | results.should.have.status(403); 407 | results.text.should.eq('Forbidden'); 408 | }); 409 | 410 | describe('delete a case', () => { 411 | before(async () => { 412 | await caseService.deleteAllRows(); 413 | 414 | const caseParams = { 415 | organization_id: currentOrg.id, 416 | state: 'published', 417 | }; 418 | currentCase = await mockData.mockCase(caseParams); 419 | }); 420 | 421 | it('return a 200', async () => { 422 | const newParams = { 423 | caseId: currentCase.caseId, 424 | }; 425 | 426 | const results = await chai 427 | .request(server) 428 | .post(`/case/delete`) 429 | .set('Cookie', `access_token=${token}`) 430 | .set('X-Requested-With', 'XMLHttpRequest') 431 | .set('content-type', 'application/json') 432 | .send(newParams); 433 | 434 | results.should.have.status(200); 435 | }); 436 | }); 437 | 438 | describe('update a case', () => { 439 | it('returns a 422 if the external id is already taken', async () => { 440 | await mockData.mockCase({ 441 | organization_id: currentOrg.id, 442 | external_id: 'taken_external_id', 443 | state: 'unpublished', 444 | }); 445 | 446 | let currentCase = await mockData.mockCase({ 447 | organization_id: currentOrg.id, 448 | state: 'unpublished', 449 | }); 450 | 451 | let updateParams = { 452 | caseId: currentCase.caseId, 453 | externalId: 'taken_external_id', 454 | }; 455 | 456 | const results = await chai 457 | .request(server) 458 | .put(`/case`) 459 | .set('Cookie', `access_token=${token}`) 460 | .set('X-Requested-With', 'XMLHttpRequest') 461 | .set('content-type', 'application/json') 462 | .send(updateParams); 463 | 464 | results.should.have.status(422); 465 | results.body.should.be.a('object'); 466 | results.body.error.should.eq('External ID must be unique.'); 467 | }); 468 | 469 | it('return a 200', async () => { 470 | const caseParams = { 471 | organization_id: currentOrg.id, 472 | external_id: 'sdfasdfasdfasdf', 473 | state: 'unpublished', 474 | }; 475 | 476 | let currentCase = await mockData.mockCase(caseParams); 477 | 478 | let updateParams = { 479 | caseId: currentCase.caseId, 480 | externalId: 'an_external_id', 481 | }; 482 | 483 | const results = await chai 484 | .request(server) 485 | .put(`/case`) 486 | .set('Cookie', `access_token=${token}`) 487 | .set('X-Requested-With', 'XMLHttpRequest') 488 | .set('content-type', 'application/json') 489 | .send(updateParams); 490 | 491 | results.should.have.status(200); 492 | results.body.should.be.a('object'); 493 | results.body.case.externalId.should.eq('an_external_id'); 494 | }); 495 | }); 496 | 497 | describe('purges cases and points outside 30 day retention period for organization', () => { 498 | let caseOne, caseTwo; 499 | 500 | before(async () => { 501 | await caseService.deleteAllRows(); 502 | await pointService.deleteAllRows(); 503 | 504 | // Add Case & Trails 505 | let expires_at = new Date().getTime() - 86400 * 10 * 1000; 506 | caseOne = await mockData.mockCase({ 507 | organization_id: currentOrg.id, 508 | state: 'published', 509 | expires_at: new Date(expires_at), 510 | }); 511 | let trailsParams = { 512 | caseId: caseOne.caseId, 513 | startAt: new Date().getTime() - 86400 * 40 * 1000, 514 | }; 515 | await mockData.mockTrails(10, 1800, trailsParams); 516 | 517 | expires_at = new Date().getTime() + 86400 * 20 * 1000; 518 | caseTwo = await mockData.mockCase({ 519 | organization_id: currentOrg.id, 520 | state: 'published', 521 | }); 522 | trailsParams = { 523 | caseId: caseTwo.caseId, 524 | startAt: new Date().getTime() - 86400 * 20 * 1000, 525 | }; 526 | await mockData.mockTrails(10, 1800, trailsParams); 527 | }); 528 | it('return a 200', async () => { 529 | const results = await chai 530 | .request(server) 531 | .get(`/organization/cases`) 532 | .set('Cookie', `access_token=${token}`) 533 | .set('X-Requested-With', 'XMLHttpRequest') 534 | .set('content-type', 'application/json'); 535 | 536 | results.should.have.status(200); 537 | results.body.should.be.a('object'); 538 | results.body.cases.should.be.a('array'); 539 | results.body.cases.length.should.equal(1); 540 | results.body.cases[0].caseId.should.equal(caseTwo.caseId); 541 | 542 | const pointResults = await pointService.fetchRedactedPoints([ 543 | caseOne.caseId, 544 | ]); 545 | 546 | pointResults.length.should.equal(0); 547 | }); 548 | }); 549 | }); 550 | --------------------------------------------------------------------------------