├── .dockerignore ├── .env.example ├── .gitignore ├── .nvmrc ├── .prettierignore ├── CHANGELOG.md ├── Dockerfile ├── Jenkinsfile ├── Jenkinsfile.postmerge ├── LICENSE ├── README.md ├── app.js ├── app ├── api │ ├── announcements.js │ ├── assignments.js │ ├── buildUrl.js │ ├── canvasProxy.js │ ├── discussions.js │ ├── documents.js │ ├── file.js │ ├── files.js │ ├── flickrSearch.js │ ├── folders.js │ ├── images.js │ ├── index.js │ ├── kaltura.js │ ├── linksResponseHandler.js │ ├── mediaObjects.js │ ├── mediaTracks.js │ ├── modules.js │ ├── packageBookmark.js │ ├── quizzes.js │ ├── rceConfig.js │ ├── session.js │ ├── upload.js │ ├── usageRights.js │ ├── wikiPages.js │ ├── wrapCanvas.js │ └── youTubeApi.js ├── application.js ├── config.js ├── container.js ├── env.js ├── exceptions │ ├── AuthRequiredException.js │ ├── ConfigRequiredException.js │ ├── EnvRequiredException.js │ ├── HandledException.js │ ├── InvalidMediaTrackException.js │ └── TokenInvalidException.js ├── middleware.js ├── middleware │ ├── auth.js │ ├── corsProtection.js │ ├── errors.js │ ├── healthcheck.js │ ├── requestLogs.js │ └── stats.js ├── routes.js ├── setup.js ├── token.js └── utils │ ├── cipher.js │ ├── fetch.js │ ├── object.js │ ├── optionalQuery.js │ ├── search.js │ ├── sign.js │ └── sort.js ├── bin └── slack_notify ├── build.sh ├── config ├── app.js ├── index.js ├── stats.js └── token.js ├── docker-compose.override.yml.dev ├── docker-compose.yml ├── inst-cli └── docker-compose │ └── docker-compose.local.dev.yml ├── package-lock.json ├── package.json ├── shared └── mimeClass.js ├── test ├── service │ ├── api │ │ ├── announcements.test.js │ │ ├── assignments.test.js │ │ ├── canvasProxy.test.js │ │ ├── discussions.test.js │ │ ├── documents.test.js │ │ ├── file.test.js │ │ ├── files.test.js │ │ ├── flickrSearch.test.js │ │ ├── folders.test.js │ │ ├── images.test.js │ │ ├── index.test.js │ │ ├── linksResponseHandler.test.js │ │ ├── mediaObjects.test.js │ │ ├── mediaTracks.test.js │ │ ├── modules.test.js │ │ ├── packageBookmark.test.js │ │ ├── quizzes.test.js │ │ ├── rceConfig.test.js │ │ ├── session.test.js │ │ ├── upload.test.js │ │ ├── usageRights.test.js │ │ ├── wikiPages.test.js │ │ ├── wrapCanvas.test.js │ │ └── youTubeApi.test.js │ ├── application.test.js │ ├── cipher.test.js │ ├── config.test.js │ ├── container.test.js │ ├── env.test.js │ ├── exceptions │ │ ├── AuthRequiredException.test.js │ │ ├── ConfigRequiredException.test.js │ │ ├── EnvRequiredException.test.js │ │ ├── HandledException.test.js │ │ ├── InvalidMediaTrackException.test.js │ │ └── TokenInvalidException.test.js │ ├── middleware │ │ ├── auth.test.js │ │ ├── corsProtection.test.js │ │ ├── errors.test.js │ │ ├── healthcheck.test.js │ │ ├── requestLogs.test.js │ │ └── stats.test.js │ ├── token.test.js │ └── utils │ │ ├── object.test.js │ │ ├── search.test.js │ │ └── sign.test.js ├── shared │ └── mimeClass.test.js └── support │ ├── fakeConfig.js │ └── setup.js └── tmp └── .gitignore /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .gitignore 3 | README.md 4 | deploy/ 5 | .git/ 6 | docker-compose* 7 | node_modules/ 8 | tmp/ 9 | coverage/ 10 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | NODE_ENV=development 3 | STATSD_HOST=127.0.0.1 4 | STATSD_PORT=8125 5 | STATS_PREFIX=rceapi 6 | CIPHER_PASSWORD=TEMP_PASSWORD 7 | ECOSYSTEM_SECRET=astringthatisactually32byteslong 8 | ECOSYSTEM_KEY=astringthatisactually32byteslong 9 | FLICKR_API_KEY=fake_key 10 | YOUTUBE_API_KEY=fake_key -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log 3 | docker-compose.override.yml 4 | /docker-compose.local.dev.yml 5 | coverage/ 6 | .nyc_output/ 7 | .env 8 | .vscode 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | package.json 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | Add the editor field toe the api/wikiPages response 11 | 12 | ## [1.27.2] 13 | 14 | In the request to upload files, forward the no_redirect parameter to canvas if it's present in the request body 15 | 16 | ## [1.27.1] 17 | 18 | Allow includes params to pass through to Canvas 19 | 20 | ## [1.27] 21 | 22 | Upgrade to Node 18 23 | 24 | ## [1.26] 25 | 26 | Update mime types for files 27 | Add media_attachment urls to api 28 | 29 | ## [1.25] 30 | 31 | Remove unsplash code 32 | 33 | ## [1.24] 34 | 35 | Fix malformed URI error for search 36 | 37 | ## [1.23] 38 | 39 | Response for files list includes file category 40 | 41 | ## [1.21] 42 | 43 | Rename Buttons & Icon text to Icon Maker 44 | 45 | ## [1.20] 46 | 47 | ### Added 48 | 49 | - Responses from the documents API now include the file's media_entry_id. This ID corresponds to a Canvas MediaObject. 50 | - The files API now accepts query params that communicate to Canvas the "replaced by" chain context 51 | 52 | ## [1.19] 53 | 54 | ### Added 55 | 56 | - A changelog to make changes more clear 57 | 58 | ### Changed 59 | 60 | - The canvas-rce-api Docker image now uses Node 16 and NPM 8 61 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=${TARGETPLATFORM:-"linux/amd64"} instructure/node-passenger:18 2 | COPY --chown=docker:docker . /usr/src/app 3 | 4 | RUN npm ci 5 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env groovy 2 | pipeline { 3 | agent { label 'docker' } 4 | stages { 5 | stage('Build') { 6 | steps { 7 | sh 'docker-compose build --pull' 8 | // Start a container once to create a network, otherwise there could be 9 | // a race condition in the parallel stages below. 10 | sh 'docker-compose run --rm -dT --name=web_test web' 11 | } 12 | } 13 | stage('Verify') { 14 | parallel { 15 | stage('Test') { 16 | steps { 17 | sh 'docker exec web_test npm run test-cov' 18 | sh 'docker cp $(docker ps -q -f "name=web_test"):/usr/src/app/coverage coverage' 19 | sh 'docker stop web_test' 20 | } 21 | } 22 | stage('Security') { 23 | steps { 24 | withCredentials([string(credentialsId: 'SNYK_TOKEN', variable: 'SNYK_TOKEN')]) { 25 | sh 'docker-compose run --rm -T --name=web_security -e SNYK_TOKEN=$SNYK_TOKEN web npm run security:dependency-monitor || true' 26 | } 27 | } 28 | } 29 | stage('Lint') { 30 | steps { 31 | sh 'docker-compose run --rm -dT --name=web_lint web npm run lint' 32 | } 33 | } 34 | stage('Formatting') { 35 | steps { 36 | sh 'docker-compose run --rm -dT --name=web_formatting web npm run fmt:check' 37 | } 38 | } 39 | } 40 | } 41 | } 42 | 43 | post { 44 | cleanup { 45 | sh 'docker-compose down --volumes --remove-orphans --rmi all' 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Jenkinsfile.postmerge: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env groovy 2 | pipeline { 3 | agent { label 'docker' } 4 | environment { 5 | DOCKERHUB_RW_USERNAME = 'svcmaterials' 6 | GIT_SUBJECT = sh ( 7 | script: 'git show --format=oneline --no-patch', 8 | returnStdout: true 9 | ).trim() 10 | GIT_AUTHOR = sh ( 11 | script: 'git show -s --pretty=%an', 12 | returnStdout: true 13 | ).trim() 14 | SERVICE_NAME = 'canvas-rce-api' 15 | STARLORD_IMAGE_TAG = 'starlord.inscloudgate.net/jenkins/canvas-rce-api' 16 | CONFIG_ARG = '--config .canvas-rce-api.json' 17 | } 18 | options { 19 | ansiColor("xterm") 20 | timeout(time: 50, unit: 'MINUTES') 21 | disableConcurrentBuilds() 22 | } 23 | stages { 24 | stage('Build and Push Docker Images') { 25 | parallel { 26 | stage('Cloudgate') { 27 | steps { 28 | withCredentials([sshUserPrivateKey(credentialsId: '44aa91d6-ab24-498a-b2b4-911bcb17cc35', keyFileVariable: 'SSH_KEY_PATH', usernameVariable: 'SSH_USERNAME')]) { 29 | sh ''' 30 | GIT_SSH_COMMAND='ssh -i "$SSH_KEY_PATH" -l "$SSH_USERNAME"' git clone --depth 1 ssh://${GERRIT_HOST}:29418/RichContentService 31 | ''' 32 | } 33 | 34 | dir('RichContentService') { 35 | withCredentials([sshUserPrivateKey(credentialsId: '44aa91d6-ab24-498a-b2b4-911bcb17cc35', keyFileVariable: 'SSH_KEY_PATH', usernameVariable: 'SSH_USERNAME')]) { 36 | sh ''' 37 | GIT_SSH_COMMAND='ssh -i "$SSH_KEY_PATH" -l "$SSH_USERNAME"' git submodule update --init 38 | ''' 39 | } 40 | 41 | cloudgateBuild(cgEnvironment: "build", cgVersion: "12.3", tfVersion: "0.13") 42 | } 43 | } 44 | post { 45 | failure { 46 | slackSend channel: "#rcx-eng", color: 'danger', message: "${env.SERVICE_NAME}: CG build failed (<${env.BUILD_URL}|Open>). Changes: \n - ${env.GIT_SUBJECT} [${env.GIT_AUTHOR}]" 47 | } 48 | success { 49 | slackSend channel: "#rcx-eng", color: 'good', message: "${env.SERVICE_NAME}: CG build successful (<${env.BUILD_URL}|Open>). Changes: \n - ${env.GIT_SUBJECT} [${env.GIT_AUTHOR}]" 50 | } 51 | } 52 | } 53 | stage(':latest') { 54 | steps { 55 | script { 56 | withMultiPlatformBuilder { 57 | withCredentials([string(credentialsId: 'dockerhub-materials-rw', variable: 'DOCKERHUB_RW_PASSWORD')]) { 58 | sh 'echo $DOCKERHUB_RW_PASSWORD | docker login --username $DOCKERHUB_RW_USERNAME --password-stdin' 59 | } 60 | 61 | sh """ 62 | docker buildx build \ 63 | --builder multi-platform-builder \ 64 | --pull \ 65 | --push \ 66 | --platform=linux/amd64,linux/arm64 \ 67 | --tag "${STARLORD_IMAGE_TAG}:latest" \ 68 | --tag "${STARLORD_IMAGE_TAG}:master" \ 69 | --tag instructure/canvas-rce-api:latest \ 70 | . 71 | """ 72 | } 73 | } 74 | } 75 | } 76 | stage('New Release') { 77 | // When the git tag starts with the letter "v" followed by one or more digits, we know this commit is a new release 78 | when { tag pattern: "v\\d+", comparator: "REGEXP"} 79 | 80 | // We use an environment variable instead of a Groovy variable here because combining env var interpolation 81 | // with Groovy interpolation in a multiline Jenkins pipeline string is fraught with problems. 82 | environment { 83 | VERSION = sh ( 84 | script: "docker-compose run --rm web node -p \"require('./package.json').version\"", 85 | returnStdout: true 86 | ).trim() 87 | } 88 | 89 | steps { 90 | script { 91 | withMultiPlatformBuilder { 92 | withCredentials([string(credentialsId: 'dockerhub-materials-rw', variable: 'DOCKERHUB_RW_PASSWORD')]) { 93 | sh 'echo $DOCKERHUB_RW_PASSWORD | docker login --username $DOCKERHUB_RW_USERNAME --password-stdin' 94 | } 95 | 96 | sh """ 97 | docker buildx build \ 98 | --builder another-multi-platform-builder \ 99 | --pull \ 100 | --push \ 101 | --platform=linux/amd64,linux/arm64 \ 102 | --tag "${STARLORD_IMAGE_TAG}:${VERSION}" \ 103 | --tag "instructure/canvas-rce-api:release-${VERSION}" \ 104 | . 105 | """ 106 | } 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | post { 114 | cleanup { 115 | sh 'docker $CONFIG_ARG logout "https://index.docker.io/v1/"' 116 | sh 'docker logout "https://index.docker.io/v1/"' 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Instructure, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | require("dotenv").config(); 4 | 5 | global.fetch = require("node-fetch"); 6 | 7 | const container = require("./app/container"); 8 | const _application = require("./app/application"); 9 | 10 | // Node 17 introduced a breaking change that changed the default IP result order (ordering 11 | // ipv6 addresses first). This breaks local development when the Canvas host is localhost, 12 | // so set the default back to ipv4 first. 13 | const dns = require("node:dns"); 14 | dns.setDefaultResultOrder("ipv4first"); 15 | 16 | module.exports = container.make(_application).listen(); 17 | -------------------------------------------------------------------------------- /app/api/announcements.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/discussion_topics?per_page=${request.query.per_page}&only_announcements=1&order_by=title${search}`; 12 | case "group": 13 | return `/api/v1/groups/${request.query.contextId}/discussion_topics?per_page=${request.query.per_page}&only_announcements=1&order_by=title${search}`; 14 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 15 | default: 16 | throw new Error(`invalid contextType (${request.query.contextType})`); 17 | } 18 | } 19 | 20 | const canvasResponseHandler = linksResponseHandler((request, results) => { 21 | return results.map(announcement => { 22 | let date = announcement.posted_at; 23 | let date_type = "posted"; 24 | // posted_at date is created_at date until delayed post actually posts 25 | if (announcement.delayed_post_at > date) { 26 | date = announcement.delayed_post_at; 27 | date_type = "delayed_post"; 28 | } 29 | return { 30 | href: announcement.html_url, 31 | title: announcement.title, 32 | date, 33 | date_type 34 | }; 35 | }); 36 | }); 37 | 38 | module.exports = { canvasPath, canvasResponseHandler }; 39 | -------------------------------------------------------------------------------- /app/api/assignments.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/assignments?per_page=${request.query.per_page}&order_by=name${search}`; 12 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 13 | default: 14 | throw new Error(`invalid contextType (${request.query.contextType})`); 15 | } 16 | } 17 | 18 | const canvasResponseHandler = linksResponseHandler((request, results) => { 19 | return results.map(assignment => { 20 | return { 21 | href: assignment.html_url, 22 | title: assignment.name, 23 | published: assignment.published, 24 | date: assignment.has_overrides ? "multiple" : assignment.due_at, 25 | date_type: "due" 26 | }; 27 | }); 28 | }); 29 | 30 | module.exports = { canvasPath, canvasResponseHandler }; 31 | -------------------------------------------------------------------------------- /app/api/buildUrl.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function buildUrl(domain, path) { 4 | var protocol = "http"; 5 | if (process.env.HTTP_PROTOCOL_OVERRIDE) { 6 | protocol = process.env.HTTP_PROTOCOL_OVERRIDE; 7 | } else if (process.env.NODE_ENV === "production") { 8 | protocol = "https"; 9 | } 10 | return protocol + "://" + domain + (path || ""); 11 | } 12 | 13 | module.exports = buildUrl; 14 | -------------------------------------------------------------------------------- /app/api/canvasProxy.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const crypto = require("crypto"); 4 | const parseLinkHeader = require("parse-link-header"); 5 | const sign = require("../utils/sign"); 6 | const { parseFetchResponse } = require("../utils/fetch"); 7 | 8 | function signatureFor(string_to_sign) { 9 | const shared_secret = process.env.ECOSYSTEM_SECRET; 10 | const hmac = crypto.createHmac("sha512", shared_secret); 11 | hmac.write(string_to_sign); 12 | hmac.end(); 13 | const hmacString = hmac.read(); 14 | return Buffer.from(hmacString).toString("base64"); 15 | } 16 | 17 | function requestHeaders(tokenString, req) { 18 | const reqIdSignature = signatureFor(req.id); 19 | return { 20 | Authorization: "Bearer " + tokenString, 21 | "User-Agent": req.get("User-Agent"), 22 | "X-Request-Context-Id": Buffer.from(req.id).toString("base64"), 23 | "X-Request-Context-Signature": reqIdSignature, 24 | Accept: "application/json+canvas-string-ids" 25 | }; 26 | } 27 | 28 | // looks for a Link header. if there is one, looks for a rel="next" link in it. 29 | // if there is one, pulls it's value out and sticks it into response.bookmark 30 | function parseBookmark(response) { 31 | // request downcases all headers 32 | const header = response.headers.link; 33 | if (header) { 34 | const links = parseLinkHeader(header[0]); 35 | if (links.next) { 36 | response.bookmark = sign.sign(links.next.url); 37 | } 38 | } 39 | return response; 40 | } 41 | 42 | function collectStats(req, promiseToTime) { 43 | const startTime = Date.now(); 44 | return promiseToTime().then(result => { 45 | req.timers = req.timers || {}; 46 | req.timers.canvas_time = Date.now() - startTime; 47 | return result; 48 | }); 49 | } 50 | 51 | function catchStatusCodeError(err) { 52 | if (err.name === "StatusCodeError") { 53 | return err.response; 54 | } 55 | throw err; 56 | } 57 | 58 | function fetch(url, req, tokenString) { 59 | return collectStats(req, () => 60 | global 61 | .fetch(url, { 62 | headers: requestHeaders(tokenString, req) 63 | }) 64 | .then(parseFetchResponse) 65 | .then(parseBookmark) 66 | .catch(catchStatusCodeError) 67 | ); 68 | } 69 | 70 | function send(method, url, req, tokenString, body) { 71 | return collectStats(req, () => 72 | global 73 | .fetch(url, { 74 | method, 75 | headers: { 76 | ...requestHeaders(tokenString, req), 77 | "Content-Type": "application/json" 78 | }, 79 | body: typeof body === "string" ? body : JSON.stringify(body) 80 | }) 81 | .then(parseFetchResponse) 82 | .catch(catchStatusCodeError) 83 | ); 84 | } 85 | 86 | module.exports = { fetch, send }; 87 | -------------------------------------------------------------------------------- /app/api/discussions.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/discussion_topics?per_page=${request.query.per_page}&order_by=title${search}`; 12 | case "group": 13 | return `/api/v1/groups/${request.query.contextId}/discussion_topics?per_page=${request.query.per_page}&order_by=title${search}`; 14 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 15 | default: 16 | throw new Error(`invalid contextType (${request.query.contextType})`); 17 | } 18 | } 19 | 20 | const canvasResponseHandler = linksResponseHandler((request, results) => { 21 | return results.map(discussion => { 22 | let date = null; 23 | let date_type = null; 24 | if (discussion.assignment && discussion.assignment.due_at) { 25 | date = discussion.assignment.due_at; 26 | date_type = "due"; 27 | } else { 28 | date = discussion.todo_date || null; 29 | date_type = "todo"; 30 | } 31 | return { 32 | href: discussion.html_url, 33 | title: discussion.title, 34 | published: discussion.published, 35 | date, 36 | date_type 37 | }; 38 | }); 39 | }); 40 | 41 | module.exports = { canvasPath, canvasResponseHandler }; 42 | -------------------------------------------------------------------------------- /app/api/documents.js: -------------------------------------------------------------------------------- 1 | /* 2 | * While this is the /api/documents api, it should probably get renamed to something 3 | * like filesInContext, since it can pull all files in a context filtered by content-type 4 | * First use is for the documents pane of the content tray for the canvas-rce, but 5 | * it can be used for images and media files too 6 | */ 7 | "use strict"; 8 | 9 | const packageBookmark = require("./packageBookmark"); 10 | const { getArrayQueryParam } = require("../utils/object"); 11 | const { getSearch } = require("../utils/search"); 12 | const { optionalQuery } = require("../utils/optionalQuery"); 13 | 14 | function getContentTypes(query) { 15 | const list = getArrayQueryParam(query.content_types); 16 | if (list && list.length) { 17 | return "&" + list.map((t) => `content_types[]=${t}`).join("&"); 18 | } 19 | return ""; 20 | } 21 | 22 | function getNotContentTypes(query) { 23 | const list = getArrayQueryParam(query.exclude_content_types); 24 | if (list && list.length) { 25 | return "&" + list.map((t) => `exclude_content_types[]=${t}`).join("&"); 26 | } 27 | return ""; 28 | } 29 | 30 | function getContext(query) { 31 | switch (query.contextType) { 32 | case "course": 33 | return "courses"; 34 | case "group": 35 | return "groups"; 36 | case "user": 37 | return "users"; 38 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 39 | default: 40 | throw new Error("invalid contextType"); 41 | } 42 | } 43 | 44 | const validSortFields = [ 45 | "name", 46 | "size", 47 | "created_at", 48 | "updated_at", 49 | "content_type", 50 | "user", 51 | ]; 52 | 53 | function getSort(query) { 54 | if (!query.sort) { 55 | return ""; 56 | } 57 | let orderby = query.sort; 58 | if (!validSortFields.includes(orderby)) { 59 | throw new Error("invalid sort"); 60 | } 61 | const order = query.order === "desc" ? "desc" : "asc"; 62 | return `&sort=${orderby}&order=${order}`; 63 | } 64 | 65 | function getPreview(query) { 66 | return query.preview ? `&${query.preview}` : ""; 67 | } 68 | function canvasPath(request) { 69 | let content_types = getContentTypes(request.query); 70 | let exclude_content_types = getNotContentTypes(request.query); 71 | let sort = getSort(request.query); 72 | let search = getSearch(request.query); 73 | let context = getContext(request.query); 74 | let preview = getPreview(request.query); 75 | const category = optionalQuery(request.query, "category"); 76 | 77 | return `/api/v1/${context}/${request.query.contextId}/files?per_page=${request.query.per_page}&use_verifiers=0${content_types}${exclude_content_types}${sort}${search}${preview}${category}`; 78 | } 79 | 80 | const svg_re = /image\/svg/; 81 | function canvasResponseHandler(request, response, canvasResponse) { 82 | response.status(canvasResponse.statusCode); 83 | if (canvasResponse.statusCode === 200) { 84 | const files = canvasResponse.body; 85 | const transformedFiles = files.map((file) => { 86 | // svg files come back from canvas without a thumbnail 87 | // let's use the file's url 88 | let thumbnail_url = file.thumbnail_url; 89 | if (!thumbnail_url && svg_re.test(file["content-type"])) { 90 | thumbnail_url = file.url.replace(/\?.*$/, ""); 91 | } 92 | 93 | return { 94 | id: file.id, 95 | filename: file.filename, 96 | thumbnail_url: thumbnail_url, 97 | display_name: file.display_name, 98 | preview_url: file.preview_url, 99 | href: file.url, 100 | download_url: file.url, 101 | content_type: file["content-type"], 102 | published: !file.locked, 103 | hidden_to_user: file.hidden, 104 | locked_for_user: file.locked_for_user, 105 | unlock_at: file.unlock_at, 106 | lock_at: file.lock_at, 107 | date: file.created_at, 108 | uuid: file.uuid, 109 | media_entry_id: file.media_entry_id, 110 | }; 111 | }); 112 | 113 | response.send({ 114 | files: transformedFiles, 115 | bookmark: packageBookmark(request, canvasResponse.bookmark), 116 | }); 117 | } else { 118 | response.send(canvasResponse.body); 119 | } 120 | } 121 | 122 | module.exports = { canvasPath, canvasResponseHandler }; 123 | -------------------------------------------------------------------------------- /app/api/file.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { parse, format } = require("url"); 4 | const { fileEmbed } = require("../../shared/mimeClass"); 5 | const { optionalQuery } = require("../utils/optionalQuery"); 6 | 7 | function canvasPath(request) { 8 | const uri = parse(`/api/v1/files/${request.params.fileId}`); 9 | let query = {}; 10 | if (request.query.replacement_chain_context_type) { 11 | query["replacement_chain_context_type"] = 12 | request.query.replacement_chain_context_type; 13 | } 14 | if (request.query.replacement_chain_context_id) { 15 | query["replacement_chain_context_id"] = 16 | request.query.replacement_chain_context_id; 17 | } 18 | let include = ["preview_url"]; 19 | if (request.query.include) { 20 | include = include.concat(request.query.include); 21 | } 22 | query["include"] = include; 23 | uri.query = query; 24 | return format(uri); 25 | } 26 | 27 | function canvasResponseHandler(request, response, canvasResponse) { 28 | response.status(canvasResponse.statusCode); 29 | if (canvasResponse.statusCode === 200) { 30 | const file = canvasResponse.body; 31 | response.send({ 32 | id: file.id, 33 | type: file["content-type"], 34 | name: file.display_name || file.filename, 35 | url: file.url, 36 | preview_url: file.preview_url, 37 | embed: fileEmbed(file), 38 | restricted_by_master_course: file.restricted_by_master_course, 39 | is_master_course_child_content: file.is_master_course_child_content, 40 | is_master_course_master_content: file.is_master_course_master_content 41 | }); 42 | } else { 43 | response.send(canvasResponse.body); 44 | } 45 | } 46 | 47 | module.exports = { canvasPath, canvasResponseHandler }; 48 | -------------------------------------------------------------------------------- /app/api/files.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const packageBookmark = require("./packageBookmark"); 4 | const { fileEmbed } = require("../../shared/mimeClass"); 5 | const { getSearch } = require("../utils/search"); 6 | const { getSort } = require("../utils/sort"); 7 | const { optionalQuery } = require("../utils/optionalQuery"); 8 | 9 | function canvasPath(request) { 10 | if (request.query.contextType === "user") { 11 | return `/api/v1/users/${request.query.contextId}/files?per_page=${ 12 | request.query.per_page 13 | }&include[]=preview_url&use_verifiers=0${getSearch(request.query)}`; 14 | } else { 15 | return `/api/v1/folders/${request.params.folderId}/files?per_page=${ 16 | request.query.per_page 17 | }&include[]=preview_url&use_verifiers=0${getSearch(request.query)}${getSort( 18 | request.query 19 | )}${optionalQuery(request.query, "category")}`; 20 | } 21 | } 22 | 23 | function canvasResponseHandler(request, response, canvasResponse) { 24 | response.status(canvasResponse.statusCode); 25 | if (canvasResponse.statusCode === 200) { 26 | const files = canvasResponse.body; 27 | 28 | response.send({ 29 | files: files.map(file => { 30 | return { 31 | createdAt: file.created_at, 32 | id: file.id, 33 | uuid: file.uuid, 34 | type: file["content-type"], 35 | name: file.display_name || file.filename, 36 | url: file.url, 37 | embed: fileEmbed(file), 38 | folderId: file.folder_id, 39 | iframeUrl: file.embedded_iframe_url, 40 | thumbnailUrl: file.thumbnail_url || file.url, 41 | category: file.category, 42 | mediaEntryId: file.media_entry_id 43 | }; 44 | }), 45 | bookmark: packageBookmark(request, canvasResponse.bookmark) 46 | }); 47 | } else { 48 | response.send(canvasResponse.body); 49 | } 50 | } 51 | 52 | module.exports = { canvasPath, canvasResponseHandler }; 53 | -------------------------------------------------------------------------------- /app/api/flickrSearch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { parseFetchResponse } = require("../utils/fetch"); 4 | 5 | const flickrBase = "https://api.flickr.com/services/rest"; 6 | // extras=needs_interstitial is required to get undocumented needs_interstitial 7 | // photo property in the results 8 | const flickrQuery = 9 | "method=flickr.photos.search&format=json&sort=relevance&license=1,2,3,4,5,6&per_page=15&nojsoncallback=1&extras=needs_interstitial"; 10 | 11 | function getFlickrResults(searchTerm) { 12 | const flickrKey = process.env.FLICKR_API_KEY; 13 | const encodedTerm = encodeURIComponent(searchTerm); 14 | const queryAddendum = `api_key=${flickrKey}&text=${encodedTerm}`; 15 | const url = `${flickrBase}?${flickrQuery}&${queryAddendum}`; 16 | return global.fetch(url).then(parseFetchResponse); 17 | } 18 | 19 | function transformSearchResults(results) { 20 | return ( 21 | results.body.photos.photo 22 | // needs_interstitial is an undcoumented parameter of the photo object. 23 | // it seems to be reliable at identifying nsfw rsults where safe and the 24 | // safe_search filter are not. this should be the first thing to check if 25 | // nsfw results come through in the future. 26 | .filter(photo => photo.needs_interstitial != 1) 27 | .map(photo => { 28 | const url = `https://farm${photo.farm}.static.flickr.com/${photo.server}/${photo.id}_${photo.secret}.jpg`; 29 | const link = `https://www.flickr.com/photos/${photo.owner}/${photo.id}`; 30 | return { 31 | id: photo.id, 32 | title: photo.title, 33 | href: url, 34 | link: link 35 | }; 36 | }) 37 | ); 38 | } 39 | 40 | // get results from Flickr API 41 | async function flickrSearch(req, response) { 42 | const searchTerm = req.query.term; 43 | try { 44 | const searchResults = await getFlickrResults(searchTerm); 45 | const images = transformSearchResults(searchResults); 46 | response.status(searchResults.statusCode); 47 | response.send(images); 48 | } catch (e) { 49 | // TODO: better error handling 50 | process.stderr.write("Flickr Search Failed"); 51 | process.stderr.write("" + e); 52 | response.status(500); 53 | response.send("Internal Error, see server logs"); 54 | } 55 | } 56 | 57 | module.exports = flickrSearch; 58 | -------------------------------------------------------------------------------- /app/api/folders.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const packageBookmark = require("./packageBookmark"); 4 | 5 | function transformBody(baseUrl, folders) { 6 | if (!Array.isArray(folders)) { 7 | folders = [folders]; 8 | } 9 | return folders.map(folder => { 10 | return { 11 | id: folder.id, 12 | parentId: folder.parent_folder_id, 13 | name: folder.name, 14 | filesUrl: `${baseUrl}/files/${folder.id}`, 15 | foldersUrl: `${baseUrl}/folders/${folder.id}`, 16 | lockedForUser: folder.locked_for_user, 17 | contextType: folder.context_type, 18 | contextId: folder.context_id, 19 | canUpload: folder.can_upload 20 | }; 21 | }); 22 | } 23 | 24 | function canvasPath(request) { 25 | const id = request.params.folderId; 26 | const isIconMaker = id === "icon_maker"; 27 | if (id && id !== "all" && !isIconMaker && id !== "media") { 28 | return `/api/v1/folders/${request.params.folderId}/folders?per_page=${request.query.per_page}`; 29 | } 30 | const byPath = id === "all" ? "" : "/by_path"; 31 | switch (request.query.contextType) { 32 | case "course": 33 | if (isIconMaker) { 34 | return `/api/v1/courses/${request.query.contextId}/folders/${id}`; 35 | } 36 | if (id === "media") { 37 | return `/api/v1/courses/${request.query.contextId}/folders/media`; 38 | } 39 | return `/api/v1/courses/${request.query.contextId}/folders${byPath}?per_page=${request.query.per_page}`; 40 | case "group": 41 | if (id === "media") { 42 | return `/api/v1/groups/${request.query.contextId}/folders/media`; 43 | } 44 | return `/api/v1/groups/${request.query.contextId}/folders${byPath}?per_page=${request.query.per_page}`; 45 | case "user": 46 | if (id === "media") { 47 | return `/api/v1/users/${request.query.contextId}/folders/media`; 48 | } 49 | return `/api/v1/users/${request.query.contextId}/folders${byPath}?per_page=${request.query.per_page}`; 50 | default: 51 | throw new Error(`invalid contextType (${request.query.contextType})`); 52 | } 53 | } 54 | 55 | function canvasResponseHandler(request, response, canvasResponse) { 56 | response.status(canvasResponse.statusCode); 57 | if (canvasResponse.statusCode === 200) { 58 | const folders = canvasResponse.body; 59 | const protocol = request.get("X-Forwarded-Proto") || request.protocol; 60 | const baseUrl = `${protocol}://${request.get("host")}/api`; 61 | response.send({ 62 | folders: transformBody(baseUrl, folders), 63 | bookmark: packageBookmark(request, canvasResponse.bookmark) 64 | }); 65 | } else { 66 | response.send(canvasResponse.body); 67 | } 68 | } 69 | 70 | module.exports = { transformBody, canvasPath, canvasResponseHandler }; 71 | -------------------------------------------------------------------------------- /app/api/images.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /** 4 | * NOTE: the new RCE in canvas now uses the /documents?context_types=image 5 | * endpoint to query for images rather than this /images endpoint 6 | */ 7 | 8 | const packageBookmark = require("./packageBookmark"); 9 | const { getSearch } = require("../utils/search"); 10 | 11 | function canvasPath(request) { 12 | switch (request.query.contextType) { 13 | case "course": 14 | return `/api/v1/courses/${request.query.contextId}/files?per_page=${request.query.per_page}&content_types[]=image&use_verifiers=0`; 15 | case "group": 16 | return `/api/v1/groups/${request.query.contextId}/files?per_page=${request.query.per_page}&content_types[]=image&use_verifiers=0`; 17 | case "user": 18 | return `/api/v1/users/${request.query.contextId}/files?per_page=${request.query.per_page}&content_types[]=image&use_verifiers=0`; 19 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 20 | default: 21 | throw new Error("invalid contextType"); 22 | } 23 | } 24 | 25 | function canvasResponseHandler(request, response, canvasResponse) { 26 | response.status(canvasResponse.statusCode); 27 | if (canvasResponse.statusCode === 200) { 28 | const images = canvasResponse.body; 29 | const transformedImages = images.map(image => { 30 | return { 31 | id: image.id, 32 | filename: image.filename, 33 | thumbnail_url: image.thumbnail_url, 34 | display_name: image.display_name, 35 | preview_url: image.url, 36 | href: image.url 37 | }; 38 | }); 39 | 40 | response.send({ 41 | images: transformedImages, 42 | bookmark: packageBookmark(request, canvasResponse.bookmark) 43 | }); 44 | } else { 45 | response.send(canvasResponse.body); 46 | } 47 | } 48 | 49 | module.exports = { canvasPath, canvasResponseHandler }; 50 | -------------------------------------------------------------------------------- /app/api/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { actionKeyMiddleware: statsdKey } = require("../middleware/stats"); 4 | const _auth = require("../middleware/auth"); 5 | const wrapCanvas = require("./wrapCanvas"); 6 | const flickrSearch = require("./flickrSearch"); 7 | const getSessionHandler = require("./session"); 8 | const announcements = require("./announcements"); 9 | const assignments = require("./assignments"); 10 | const discussions = require("./discussions"); 11 | const modules = require("./modules"); 12 | const quizzes = require("./quizzes"); 13 | const rceConfig = require("./rceConfig"); 14 | const wikiPages = require("./wikiPages"); 15 | const kaltura = require("./kaltura"); 16 | const media_objects = require("./mediaObjects"); 17 | const media_tracks = require("./mediaTracks"); 18 | const file = require("./file"); 19 | const files = require("./files"); 20 | const folders = require("./folders"); 21 | const images = require("./images"); 22 | const usageRights = require("./usageRights"); 23 | const upload = require("./upload"); 24 | const youTubeTitle = require("./youTubeApi"); 25 | const documents = require("./documents"); 26 | 27 | function inject() { 28 | return [_auth]; 29 | } 30 | 31 | function init(auth) { 32 | return { 33 | applyToApp(app) { 34 | app.get( 35 | "/api/announcements", 36 | statsdKey("api", "announcements"), 37 | auth, 38 | wrapCanvas(announcements) 39 | ); 40 | app.get( 41 | "/api/assignments", 42 | statsdKey("api", "assignments"), 43 | auth, 44 | wrapCanvas(assignments) 45 | ); 46 | app.get( 47 | "/api/discussions", 48 | statsdKey("api", "discussions"), 49 | auth, 50 | wrapCanvas(discussions) 51 | ); 52 | app.get( 53 | "/api/modules", 54 | statsdKey("api", "modules"), 55 | auth, 56 | wrapCanvas(modules) 57 | ); 58 | app.get( 59 | "/api/quizzes", 60 | statsdKey("api", "quizzes"), 61 | auth, 62 | wrapCanvas(quizzes) 63 | ); 64 | app.get( 65 | "/api/rceConfig", 66 | statsdKey("api", "rceConfig"), 67 | auth, 68 | wrapCanvas(rceConfig) 69 | ); 70 | app.get( 71 | "/api/wikiPages", 72 | statsdKey("api", "wikiPages"), 73 | auth, 74 | wrapCanvas(wikiPages) 75 | ); 76 | app.get("/api/files", statsdKey("api", "files"), auth, wrapCanvas(files)); 77 | app.get( 78 | "/api/files/:folderId", 79 | statsdKey("api", "files"), 80 | auth, 81 | wrapCanvas(files) 82 | ); 83 | app.get( 84 | "/api/documents", 85 | statsdKey("api", "files"), 86 | auth, 87 | wrapCanvas(documents) 88 | ); 89 | app.get( 90 | "/api/file/:fileId", 91 | statsdKey("api", "file"), 92 | auth, 93 | wrapCanvas(file) 94 | ); 95 | app.get( 96 | "/api/folders/:folderId?", 97 | statsdKey("api", "folders"), 98 | auth, 99 | wrapCanvas(folders) 100 | ); 101 | app.get( 102 | "/api/images", 103 | statsdKey("api", "images"), 104 | auth, 105 | wrapCanvas(images) 106 | ); 107 | app.post( 108 | "/api/upload", 109 | statsdKey("api", "upload"), 110 | auth, 111 | wrapCanvas(upload, { method: "POST" }) 112 | ); 113 | app.post( 114 | "/api/usage_rights", 115 | statsdKey("api", "usage_rights"), 116 | auth, 117 | wrapCanvas(usageRights, { method: "PUT" }) 118 | ); 119 | app.get( 120 | "/api/flickr_search", 121 | statsdKey("api", "flickr_search"), 122 | auth, 123 | flickrSearch 124 | ); 125 | 126 | app.get( 127 | "/api/session", 128 | statsdKey("api", "session"), 129 | auth, 130 | getSessionHandler 131 | ); 132 | app.get( 133 | "/api/youtube_title", 134 | statsdKey("api", "youtube_title"), 135 | auth, 136 | youTubeTitle 137 | ); 138 | app.post( 139 | "/api/v1/services/kaltura_session", 140 | statsdKey("api", "kaltura_session"), 141 | auth, 142 | wrapCanvas(kaltura, { method: "POST" }) 143 | ); 144 | app.post( 145 | "/api/media_objects", 146 | statsdKey("api", "media_objects"), 147 | auth, 148 | wrapCanvas(media_objects, { method: "POST" }) 149 | ); 150 | app.post( 151 | "/api/media_attachments", 152 | statsdKey("api", "media_attachments"), 153 | auth, 154 | wrapCanvas(media_objects, { method: "POST" }) 155 | ); 156 | app.get( 157 | "/api/media_objects", 158 | statsdKey("api", "media_objects"), 159 | auth, 160 | wrapCanvas(media_objects) 161 | ); 162 | app.put( 163 | "/api/media_objects/:mediaObjectId", 164 | statsdKey("api", "media_objects"), 165 | auth, 166 | wrapCanvas(media_objects, { method: "PUT" }) 167 | ); 168 | app.put( 169 | "/api/media_attachments/:mediaAttachmentId", 170 | statsdKey("api", "media_attachments"), 171 | auth, 172 | wrapCanvas(media_objects, { method: "PUT" }) 173 | ); 174 | app.get( 175 | "/api/media_objects/:mediaObjectId/media_tracks", 176 | statsdKey("api", "media_tracks"), 177 | auth, 178 | wrapCanvas(media_tracks) 179 | ); 180 | app.get( 181 | "/api/media_attachments/:mediaAttachmentId/media_tracks", 182 | statsdKey("api", "media_tracks"), 183 | auth, 184 | wrapCanvas(media_tracks) 185 | ); 186 | app.put( 187 | "/api/media_objects/:mediaObjectId/media_tracks", 188 | statsdKey("api", "media_tracks"), 189 | auth, 190 | wrapCanvas(media_tracks, { method: "PUT" }) 191 | ); 192 | app.put( 193 | "/api/media_attachments/:mediaAttachmentId/media_tracks", 194 | statsdKey("api", "media_tracks"), 195 | auth, 196 | wrapCanvas(media_tracks, { method: "PUT" }) 197 | ); 198 | } 199 | }; 200 | } 201 | 202 | module.exports = { inject, init, singleton: true }; 203 | -------------------------------------------------------------------------------- /app/api/kaltura.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function canvasPath() { 4 | return "/api/v1/services/kaltura_session?include_upload_config=1"; 5 | } 6 | 7 | module.exports = { canvasPath }; 8 | -------------------------------------------------------------------------------- /app/api/linksResponseHandler.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const packageBookmark = require("./packageBookmark"); 4 | 5 | // creates a function appropriate for use a a canvas wrapper's 6 | // canvasResponseHandler that just translates the canvas response's body 7 | // according to the given function, then returns it as "links" along with a 8 | // packaged bookmark 9 | function linksResponseHandler(linksFromResponseBody) { 10 | return (request, response, canvasResponse) => { 11 | if (canvasResponse.statusCode == 200) { 12 | response.status(200); 13 | response.send({ 14 | links: linksFromResponseBody(request, canvasResponse.body), 15 | bookmark: packageBookmark(request, canvasResponse.bookmark) 16 | }); 17 | } else { 18 | response.status(canvasResponse.statusCode); 19 | response.send(canvasResponse.body); 20 | } 21 | }; 22 | } 23 | 24 | module.exports = linksResponseHandler; 25 | -------------------------------------------------------------------------------- /app/api/mediaObjects.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const packageBookmark = require("./packageBookmark"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | // to limit the results to media_objects associated with a course 7 | // the api includes contextType and contextId query_string parameters. 8 | function getContext(query) { 9 | switch (query.contextType) { 10 | case "course": 11 | case "courses": 12 | return "course"; 13 | case "group": 14 | case "groups": 15 | return "group"; 16 | case "user": 17 | case "users": 18 | return ""; 19 | default: 20 | throw new Error(`invalid contextType ${query.contextType}`); 21 | } 22 | } 23 | 24 | const validSortFields = ["title", "date"]; 25 | const validSortDirs = ["asc", "desc"]; 26 | const validContextTypes = ["course", "user", "group"]; 27 | 28 | function getSort(query) { 29 | if (!query.sort) { 30 | return ""; 31 | } 32 | let orderby = query.sort; 33 | if (!validSortFields.includes(orderby)) { 34 | throw new Error("invalid sort"); 35 | } 36 | if (orderby === "date") { 37 | orderby = "created_at"; 38 | } 39 | return `&sort=${orderby}${getSortDir(query)}`; 40 | } 41 | 42 | function getSortDir(query) { 43 | if (!query.order) { 44 | return "asc"; 45 | } 46 | 47 | const dir = query.order; 48 | if (!validSortDirs.includes(dir)) { 49 | throw new Error("invalid sort order"); 50 | } 51 | 52 | return `&order=${dir}`; 53 | } 54 | 55 | function canvasPath(request) { 56 | switch (request.method) { 57 | case "GET": 58 | return canvasPathGET(request); 59 | case "PUT": 60 | return canvasPathPUT(request); 61 | case "POST": 62 | return canvasPathPOST(request); 63 | default: 64 | throw new Error("invalid request"); 65 | } 66 | } 67 | 68 | function canvasPathGET(request) { 69 | const contextType = request.query.contextType && getContext(request.query); 70 | const contextId = request.query.contextId; 71 | 72 | const exclude = "&exclude[]=sources&exclude[]=tracks"; 73 | const sort = getSort(request.query); 74 | const search = getSearch(request.query); 75 | let baseURI = "/api/v1/media_objects"; 76 | 77 | if (contextType) { 78 | if (!validContextTypes.includes(contextType)) { 79 | throw new Error("Invalid contextType"); 80 | } 81 | if (!contextId) { 82 | throw new Error("A contextId is required if contextType is provided"); 83 | } 84 | if (contextType === "course") { 85 | baseURI = `/api/v1/courses/${contextId}/media_objects`; 86 | } else if (contextType === "group") { 87 | baseURI = `/api/v1/groups/${contextId}/media_objects`; 88 | } 89 | } 90 | 91 | return `${baseURI}?per_page=${request.query.per_page}&use_verifiers=0${exclude}${sort}${search}`; 92 | } 93 | 94 | function canvasPathPOST(request) { 95 | return request._parsedUrl.path == "/api/media_attachments" 96 | ? "/api/v1/media_attachments" 97 | : "/api/v1/media_objects"; 98 | } 99 | 100 | function canvasPathPUT(request) { 101 | const user_entered_title = request.query.user_entered_title; 102 | const moid = request.params.mediaAttachmentId || request.params.mediaObjectId; 103 | const path = request.params.mediaAttachmentId 104 | ? "media_attachments" 105 | : "media_objects"; 106 | return `/api/v1/${path}/${moid}?user_entered_title=${encodeURIComponent( 107 | user_entered_title 108 | )}`; 109 | } 110 | 111 | function canvasResponseHandler(request, response, canvasResponse) { 112 | response.status(canvasResponse.statusCode); 113 | if (canvasResponse.statusCode === 200) { 114 | const mediaObjs = canvasResponse.body; 115 | if (Array.isArray(mediaObjs)) { 116 | const transformedObjs = mediaObjs.map(obj => { 117 | return transformMediaObject(obj); 118 | }); 119 | 120 | response.send({ 121 | files: transformedObjs, 122 | bookmark: packageBookmark(request, canvasResponse.bookmark) 123 | }); 124 | } else { 125 | const mediaObj = transformMediaObject(mediaObjs); 126 | response.send(mediaObj); 127 | } 128 | } else { 129 | response.send(canvasResponse.body); 130 | } 131 | } 132 | 133 | function transformMediaObject(obj) { 134 | return { 135 | id: obj.media_id, 136 | title: obj.user_entered_title || obj.title, 137 | content_type: obj.media_type, 138 | media_object: obj.media_object, 139 | date: obj.created_at, 140 | published: true, // TODO: is this true? 141 | embedded_iframe_url: obj.embedded_iframe_url 142 | }; 143 | } 144 | module.exports = { canvasPath, canvasResponseHandler }; 145 | -------------------------------------------------------------------------------- /app/api/mediaTracks.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const InvalidMediaTrack = require("../exceptions/InvalidMediaTrackException"); 3 | const { getArrayQueryParam } = require("../utils/object"); 4 | 5 | function getIncludes(query) { 6 | const list = getArrayQueryParam(query.include); 7 | if (list && list.length) { 8 | return "?" + list.map(t => `include[]=${t}`).join("&"); 9 | } 10 | return ""; 11 | } 12 | 13 | function canvasPath(request) { 14 | const includes = getIncludes(request.query); 15 | const moid = request.params.mediaAttachmentId || request.params.mediaObjectId; 16 | const path = request.params.mediaAttachmentId 17 | ? "media_attachments" 18 | : "media_objects"; 19 | return `/api/v1/${path}/${moid}/media_tracks${includes}`; 20 | } 21 | 22 | function transformBody(body) { 23 | if (!Array.isArray(body)) { 24 | throw InvalidMediaTrack.badFormat(); 25 | } 26 | const tracks = body.map(t => { 27 | if (!t.locale) { 28 | throw InvalidMediaTrack.missingLocale(); 29 | } 30 | const cc = { locale: t.locale }; 31 | if (t.content) { 32 | cc.content = t.content; 33 | } 34 | return cc; 35 | }); 36 | return JSON.stringify(tracks); 37 | } 38 | 39 | function canvasResponseHandler(request, response, canvasResponse) { 40 | response.status(canvasResponse.statusCode); 41 | response.send(canvasResponse.body); 42 | } 43 | 44 | module.exports = { canvasPath, canvasResponseHandler, transformBody }; 45 | -------------------------------------------------------------------------------- /app/api/modules.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/modules?per_page=${request.query.per_page}${search}`; 12 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 13 | default: 14 | throw new Error(`invalid contextType (${request.query.contextType})`); 15 | } 16 | } 17 | 18 | const canvasResponseHandler = linksResponseHandler((request, results) => { 19 | // Canvas' API for modules doesn't have an html_url, so we have to construct 20 | // it ourselves with knowledge of Canvas' internals (boo) 21 | let prefix = `/courses/${request.query.contextId}/modules/`; 22 | return results.map(module => { 23 | return { 24 | href: prefix + module.id, 25 | title: module.name, 26 | published: module.published 27 | }; 28 | }); 29 | }); 30 | 31 | module.exports = { canvasPath, canvasResponseHandler }; 32 | -------------------------------------------------------------------------------- /app/api/packageBookmark.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const querystring = require("querystring"); 4 | 5 | // package bookmark from canvas to be embedded as a query parameter to the same 6 | // path as the current request, and with all the same query parameters as the 7 | // current request (except replacing any existing bookmark) 8 | function packageBookmark(request, bookmark) { 9 | if (bookmark) { 10 | const path = request.baseUrl + request.path; 11 | const query = Object.assign({}, request.query, { bookmark }); 12 | const qs = querystring.stringify(query); 13 | return `${request.protocol}://${request.get("Host")}${path}?${qs}`; 14 | } else { 15 | return null; 16 | } 17 | } 18 | 19 | module.exports = packageBookmark; 20 | -------------------------------------------------------------------------------- /app/api/quizzes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/all_quizzes?per_page=${request.query.per_page}${search}`; 12 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 13 | default: 14 | throw new Error(`invalid contextType (${request.query.contextType})`); 15 | } 16 | } 17 | 18 | const canvasResponseHandler = linksResponseHandler((request, results) => { 19 | return results.map(quiz => { 20 | let date = quiz.due_at; 21 | if (quiz.all_dates && quiz.all_dates.length > 1) { 22 | date = "multiple"; 23 | } 24 | return { 25 | href: quiz.html_url, 26 | title: quiz.title, 27 | published: quiz.published, 28 | date, 29 | date_type: "due", 30 | quiz_type: quiz.quiz_type 31 | }; 32 | }); 33 | }); 34 | 35 | module.exports = { canvasPath, canvasResponseHandler }; 36 | -------------------------------------------------------------------------------- /app/api/rceConfig.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function canvasPath(request) { 4 | if (!request.query.contextId) 5 | throw new Error("missing contextId parameter from query param"); 6 | 7 | const queryParamName = (contextType => { 8 | switch (contextType) { 9 | case "course": 10 | return "course_id"; 11 | case "user": 12 | return "user_id"; 13 | case "group": 14 | return "group_id"; 15 | case "account": 16 | return "account_id"; 17 | default: 18 | throw new Error(`invalid contextType (${request.query.contextType})`); 19 | } 20 | })(request.query.contextType); 21 | 22 | return `/api/v1/services/rce_config?${queryParamName}=${request.query.contextId}`; 23 | } 24 | 25 | module.exports = { canvasPath }; 26 | -------------------------------------------------------------------------------- /app/api/session.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const buildUrl = require("./buildUrl"); 4 | 5 | function getSessionHandler(req, res) { 6 | const { workflow_state, context_type, context_id, domain } = req.auth.payload; 7 | 8 | const { 9 | can_upload_files, 10 | usage_rights_required, 11 | use_high_contrast, 12 | can_create_pages 13 | } = workflow_state; 14 | 15 | res.json({ 16 | contextType: context_type, 17 | contextId: context_id, 18 | canUploadFiles: can_upload_files, 19 | usageRightsRequired: usage_rights_required, 20 | useHighContrast: use_high_contrast, 21 | canCreatePages: can_create_pages, 22 | canvasUrl: buildUrl(domain) 23 | }); 24 | } 25 | 26 | module.exports = getSessionHandler; 27 | -------------------------------------------------------------------------------- /app/api/upload.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function canvasPath(request) { 4 | switch (request.body.contextType) { 5 | case "course": 6 | return `/api/v1/courses/${request.body.contextId}/files`; 7 | case "group": 8 | return `/api/v1/groups/${request.body.contextId}/files`; 9 | case "user": 10 | return `/api/v1/users/${request.body.contextId}/files`; 11 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 12 | default: 13 | throw new Error(`invalid contextType (${request.query.contextType})`); 14 | } 15 | } 16 | 17 | function transformBody(body) { 18 | let canvasUploadPreflightBody = { 19 | name: body.file.name, 20 | size: body.file.size, 21 | contentType: body.file.type || body.file.contentType || undefined, 22 | parent_folder_id: body.file.parentFolderId, 23 | on_duplicate: body.onDuplicate || "rename", 24 | success_include: ["preview_url"], 25 | category: body.category || undefined 26 | }; 27 | if (body.no_redirect) { 28 | canvasUploadPreflightBody.no_redirect = body.no_redirect; 29 | } 30 | 31 | return canvasUploadPreflightBody; 32 | } 33 | 34 | function canvasResponseHandler(request, response, canvasResponse) { 35 | response.status(canvasResponse.statusCode); 36 | response.send(canvasResponse.body); 37 | } 38 | 39 | module.exports = { canvasPath, transformBody, canvasResponseHandler }; 40 | -------------------------------------------------------------------------------- /app/api/usageRights.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function canvasPath(request) { 4 | const { context_type, context_id } = request.auth.payload; 5 | switch (context_type) { 6 | case "Course": 7 | return `/api/v1/courses/${context_id}/usage_rights`; 8 | case "Group": 9 | return `/api/v1/groups/${context_id}/usage_rights`; 10 | case "User": 11 | return `/api/v1/users/${context_id}/usage_rights`; 12 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 13 | default: 14 | throw new Error(`invalid contextType (${context_type})`); 15 | } 16 | } 17 | 18 | function transformBody(body) { 19 | return { 20 | file_ids: [body.fileId], 21 | publish: true, 22 | usage_rights: { 23 | use_justification: body.usageRight, 24 | legal_copyright: body.copyrightHolder, 25 | license: body.ccLicense 26 | } 27 | }; 28 | } 29 | 30 | module.exports = { canvasPath, transformBody }; 31 | -------------------------------------------------------------------------------- /app/api/wikiPages.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const linksResponseHandler = require("./linksResponseHandler"); 4 | const { getSearch } = require("../utils/search"); 5 | 6 | function canvasPath(request) { 7 | const search = getSearch(request.query); 8 | 9 | switch (request.query.contextType) { 10 | case "course": 11 | return `/api/v1/courses/${request.query.contextId}/pages?sort=title&per_page=${request.query.per_page}${search}`; 12 | case "group": 13 | return `/api/v1/groups/${request.query.contextId}/pages?sort=title&per_page=${request.query.per_page}${search}`; 14 | // TODO handle as 400 Bad Request instead of 500 Internal Server Error 15 | default: 16 | throw new Error(`invalid contextType (${request.query.contextType})`); 17 | } 18 | } 19 | 20 | const canvasResponseHandler = linksResponseHandler((request, results) => { 21 | return results.map(wikiPage => { 22 | return { 23 | href: wikiPage.html_url, 24 | title: wikiPage.title, 25 | published: wikiPage.published, 26 | date: wikiPage.todo_date || null, 27 | date_type: "todo", 28 | editor: wikiPage.editor || null 29 | }; 30 | }); 31 | }); 32 | 33 | module.exports = { canvasPath, canvasResponseHandler }; 34 | -------------------------------------------------------------------------------- /app/api/wrapCanvas.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const canvasProxy = require("./canvasProxy"); 4 | const buildUrl = require("./buildUrl"); 5 | const sign = require("../utils/sign"); 6 | const { RuntimeException } = require("node-exceptions"); 7 | 8 | // by default just proxies the canvas response status and body 9 | function defaultCanvasResponseHandler(request, response, canvasResponse) { 10 | response.status(canvasResponse.statusCode); 11 | response.send(canvasResponse.body); 12 | } 13 | 14 | const DEFAULT_CANVAS_PER_PAGE_ARG = 50; 15 | 16 | // by default just returns the body as-is 17 | function defaultTransformBody(body) { 18 | return body; 19 | } 20 | 21 | // uses wrapper to determine canvas request to make, makes the request (with 22 | // appropriate jwt wrapping, request ID forwarding, etc.), and then hands the 23 | // response back to the wrapper. in the case of a token error, hands that to 24 | // the wrapper as well. 25 | function canvasApiCall(wrapper, request, response, options) { 26 | const wrappedTokenString = request.auth.wrappedToken; 27 | const canvasResponseHandler = 28 | wrapper.canvasResponseHandler || defaultCanvasResponseHandler; 29 | const transformBody = wrapper.transformBody || defaultTransformBody; 30 | let url = sign.verify(request.query.bookmark); 31 | if (!url) { 32 | request.query.per_page = 33 | request.query.per_page || DEFAULT_CANVAS_PER_PAGE_ARG; 34 | let domain = request.auth.payload.domain; 35 | let path = wrapper.canvasPath(request); 36 | url = buildUrl(domain, path); 37 | } 38 | if (options.method == "GET") { 39 | canvasProxy.fetch(url, request, wrappedTokenString).then(canvasResponse => { 40 | canvasResponseHandler(request, response, canvasResponse); 41 | }); 42 | } else if (options.method == "POST" || options.method === "PUT") { 43 | let transformedBody = transformBody(request.body); 44 | canvasProxy 45 | .send(options.method, url, request, wrappedTokenString, transformedBody) 46 | .then(canvasResponse => { 47 | canvasResponseHandler(request, response, canvasResponse); 48 | }); 49 | } else { 50 | throw new RuntimeException(`Method ${options.method} is not supported`); 51 | } 52 | } 53 | 54 | // curry a wrapper into canvasApiCall to turn it into a route 55 | // requests through it 56 | function wrapCanvas(wrapper, options) { 57 | options = options || {}; 58 | options.method = options.method || "GET"; 59 | return (request, response, next) => { 60 | try { 61 | canvasApiCall(wrapper, request, response, options); 62 | } catch (err) { 63 | next(err); 64 | } 65 | }; 66 | } 67 | 68 | module.exports = wrapCanvas; 69 | -------------------------------------------------------------------------------- /app/api/youTubeApi.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { parseFetchResponse } = require("../utils/fetch"); 4 | 5 | const ytApiBase = "https://content.googleapis.com/youtube/v3/search"; 6 | const ytApiQuery = "part=snippet&maxResults=2"; 7 | 8 | function getYouTubeUrl(vid_id) { 9 | let key = process.env.YOUTUBE_API_KEY; 10 | let queryAddendum = `q="${vid_id}"&key=${key}`; 11 | return `${ytApiBase}?${ytApiQuery}&${queryAddendum}`; 12 | } 13 | 14 | function fetchYouTubeTitle(vid_id) { 15 | const url = getYouTubeUrl(vid_id); 16 | return global.fetch(url).then(parseFetchResponse); 17 | } 18 | 19 | function parseTitle(vidId, results) { 20 | let vidTitle; 21 | results.body.items.forEach(vid => { 22 | if (vid.id.videoId === vidId) { 23 | vidTitle = vid.snippet.title; 24 | } 25 | }); 26 | return vidTitle; 27 | } 28 | 29 | function youTubeTitle(req, response) { 30 | let vidId = req.query.vid_id; 31 | fetchYouTubeTitle(vidId) 32 | .then(searchResults => { 33 | if (searchResults.statusCode >= 400) { 34 | const err = new Error("YouTube search failed"); 35 | err.body = searchResults.body; 36 | throw err; 37 | } 38 | let title = parseTitle(vidId, searchResults); 39 | if (title === undefined) { 40 | process.stderr.write( 41 | `YouTube video search not found: vid_id="${vidId}"` 42 | ); 43 | response.status(500); 44 | response.send(`Video "${vidId}" not found.`); 45 | } else { 46 | response.status(searchResults.statusCode); 47 | response.json({ id: vidId, title: title }); 48 | } 49 | }) 50 | .catch(e => { 51 | process.stderr.write("YouTube Search Failed"); 52 | process.stderr.write("" + e); 53 | process.stderr.write(getYouTubeUrl(vidId)); 54 | response.status(500); 55 | response.send("Internal Error, see server logs"); 56 | }); 57 | } 58 | 59 | module.exports = youTubeTitle; 60 | -------------------------------------------------------------------------------- /app/application.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const express = require("express"); 4 | const _stats = require("./middleware/stats"); 5 | const withMiddleware = require("./middleware"); 6 | const _env = require("./env"); 7 | const _routes = require("./routes"); 8 | 9 | function inject(provide) { 10 | return [_env, _routes, provide(console), provide(express()), _stats]; 11 | } 12 | 13 | function init(env, routes, logger, app, stats) { 14 | app.use(stats.handle); 15 | 16 | // Increase max default body size from 100kb to 300kb 17 | app.use(express.json({ limit: 300000 })) 18 | 19 | withMiddleware(app, wrappedApp => routes(wrappedApp)); 20 | const port = env.get("PORT", () => 3000); 21 | return { 22 | listen() { 23 | const server = app.listen(port); 24 | logger.log(`Rich Content Service listening on port ${port}`); 25 | return server; 26 | } 27 | }; 28 | } 29 | 30 | module.exports = { inject, init, singleton: true }; 31 | -------------------------------------------------------------------------------- /app/config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const configs = require("../config"); 4 | const { getByPath } = require("./utils/object"); 5 | const ConfigRequiredException = require("./exceptions/ConfigRequiredException"); 6 | const _env = require("./env"); 7 | 8 | function inject(provide) { 9 | return [_env, provide(configs)]; 10 | } 11 | 12 | function init(env, configs) { 13 | const store = {}; 14 | Object.keys(configs).forEach(key => (store[key] = configs[key](env))); 15 | 16 | const config = { 17 | get(path, fallback = () => null) { 18 | const val = getByPath(path, store); 19 | return val != null ? val : fallback(); 20 | }, 21 | 22 | require(path) { 23 | const val = config.get(path); 24 | if (val == null) { 25 | throw ConfigRequiredException.forPath(path); 26 | } 27 | return val; 28 | } 29 | }; 30 | 31 | return config; 32 | } 33 | 34 | module.exports = { inject, init, singleton: true }; 35 | -------------------------------------------------------------------------------- /app/container.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const inja = require("inja"); 4 | 5 | module.exports = inja(); 6 | -------------------------------------------------------------------------------- /app/env.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const EnvRequiredException = require("./exceptions/EnvRequiredException"); 4 | const { InvalidArgumentException } = require("node-exceptions"); 5 | 6 | function inject(provide) { 7 | return [provide(process.env)]; 8 | } 9 | 10 | function init(vars) { 11 | const env = { 12 | get(name, fallback = () => null) { 13 | if (typeof fallback !== "function") { 14 | throw new InvalidArgumentException("fallback must be a function"); 15 | } 16 | return vars[name] || fallback(); 17 | }, 18 | 19 | require(name) { 20 | const val = env.get(name); 21 | if (val == null) { 22 | throw EnvRequiredException.forVar(name); 23 | } 24 | return val; 25 | } 26 | }; 27 | 28 | return env; 29 | } 30 | 31 | module.exports = { inject, init, singleton: true }; 32 | -------------------------------------------------------------------------------- /app/exceptions/AuthRequiredException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const HandledException = require("./HandledException"); 4 | 5 | class AuthRequiredException extends HandledException { 6 | static tokenMissing() { 7 | return new this("Authorization token required", 401); 8 | } 9 | } 10 | 11 | module.exports = AuthRequiredException; 12 | -------------------------------------------------------------------------------- /app/exceptions/ConfigRequiredException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { LogicalException } = require("node-exceptions"); 4 | 5 | class ConfigRequiredException extends LogicalException { 6 | static forPath(path) { 7 | return new this(`Configuration required for "${path}"`); 8 | } 9 | } 10 | 11 | module.exports = ConfigRequiredException; 12 | -------------------------------------------------------------------------------- /app/exceptions/EnvRequiredException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { LogicalException } = require("node-exceptions"); 4 | 5 | class EnvRequiredException extends LogicalException { 6 | static forVar(name) { 7 | return new this(`Environment variable "${name}" is required`); 8 | } 9 | } 10 | 11 | module.exports = EnvRequiredException; 12 | -------------------------------------------------------------------------------- /app/exceptions/HandledException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { LogicalException } = require("node-exceptions"); 4 | 5 | class HandledException extends LogicalException { 6 | responseStatus() { 7 | return this.status || 500; 8 | } 9 | 10 | handle(req, res, next) { 11 | res.status(this.responseStatus()); 12 | res.end(this.message); 13 | next(); 14 | } 15 | } 16 | 17 | module.exports = HandledException; 18 | -------------------------------------------------------------------------------- /app/exceptions/InvalidMediaTrackException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const HandledException = require("./HandledException"); 4 | 5 | class InvalidMediaTrack extends HandledException { 6 | static missingLocale() { 7 | return new this(JSON.stringify({ error: "locale required" }), 400); 8 | } 9 | static badFormat() { 10 | return new this( 11 | JSON.stringify({ error: "expected an array of tracks" }), 12 | 400 13 | ); 14 | } 15 | } 16 | 17 | module.exports = InvalidMediaTrack; 18 | -------------------------------------------------------------------------------- /app/exceptions/TokenInvalidException.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const HandledException = require("./HandledException"); 4 | 5 | class TokenInvalidException extends HandledException { 6 | static before() { 7 | const msg = "Authentication token not valid yet"; 8 | return new this(msg, 401, "E_TOKEN_BEFORE_NBF"); 9 | } 10 | 11 | static expired() { 12 | const msg = "Authentication token expired"; 13 | return new this(msg, 401, "E_TOKEN_AFTER_EXP"); 14 | } 15 | 16 | static parse(originalError) { 17 | const msg = "Authentication token invalid"; 18 | const err = new this(msg, 401, "E_TOKEN_PARSE_ERROR"); 19 | err.originalError = originalError; 20 | return err; 21 | } 22 | } 23 | 24 | module.exports = TokenInvalidException; 25 | -------------------------------------------------------------------------------- /app/middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const addRequestId = require("express-request-id")(); 4 | const healthcheck = require("./middleware/healthcheck"); 5 | const requestLogging = require("./middleware/requestLogs"); 6 | const bodyParser = require("body-parser"); 7 | const corsProtection = require("./middleware/corsProtection"); 8 | const errorHandling = require("./middleware/errors"); 9 | const stats = require("./middleware/stats"); 10 | const statsdKey = stats.actionKeyMiddleware; 11 | 12 | function middleware(app, applyRoutes) { 13 | // MUST be added before request logging, 14 | // as request logging depends on the id 15 | app.use(addRequestId); 16 | app.use("/readiness", statsdKey("main", "readiness"), healthcheck()); 17 | app.use(bodyParser.json()); 18 | requestLogging.applyToApp(app); 19 | corsProtection.applyToApp(app); 20 | 21 | applyRoutes(app); 22 | 23 | // error handling needs to be applied last 24 | errorHandling.applyToApp(app, process.env.SENTRY_DSN); 25 | } 26 | 27 | module.exports = middleware; 28 | -------------------------------------------------------------------------------- /app/middleware/auth.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _token = require("../token"); 4 | const AuthRequiredException = require("../exceptions/AuthRequiredException"); 5 | 6 | function bearerToken(authorization) { 7 | const matches = /^Bearer\s(.+)/.exec(authorization); 8 | if (matches && matches[1]) { 9 | return matches[1]; 10 | } 11 | return null; 12 | } 13 | 14 | function inject() { 15 | return [_token]; 16 | } 17 | 18 | function init(token) { 19 | return async (req, res, next) => { 20 | try { 21 | const jwt = bearerToken(req.get("Authorization")); 22 | if (jwt == null) { 23 | throw AuthRequiredException.tokenMissing(); 24 | } 25 | req.auth = { 26 | token: jwt, 27 | payload: await token.verify(jwt), 28 | wrappedToken: await token.wrap(jwt) 29 | }; 30 | next(); 31 | } catch (err) { 32 | next(err); 33 | } 34 | }; 35 | } 36 | 37 | module.exports = { inject, init, singleton: true }; 38 | -------------------------------------------------------------------------------- /app/middleware/corsProtection.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const cors = require("cors"); 4 | 5 | function validateOrigin(origin, callback) { 6 | // right now the only thing we expose is a javascript file, which 7 | // is the same sort of asset as on the CDN. Because of vanity domains, 8 | // it's not worth putting in an intelligent domain whitelist. 9 | // Therefore, we won't block anyone loading the module due to cors. If 10 | // we ever expose something more sensitive, then we'll make a whitelist 11 | // that checks with canvas and rejects unapproved domains. 12 | // 13 | // Should that day come to pass, use the body of this funciton to figure 14 | // out whether the "origin" variable is a valid domain. if it is, callback 15 | // with (null, true), otherwise callback with (null, false) 16 | callback(null, true); 17 | } 18 | 19 | function applyToApp(app) { 20 | var corsMiddleware = cors({ 21 | origin: validateOrigin 22 | }); 23 | app.use(corsMiddleware); 24 | } 25 | 26 | module.exports = { applyToApp, validateOrigin }; 27 | -------------------------------------------------------------------------------- /app/middleware/errors.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | /*eslint no-console: 0*/ 4 | /*eslint no-unused-vars: 0*/ 5 | 6 | const raven = require("raven"); 7 | 8 | function ravenDataCallback(data, environment) { 9 | data.tags = data.tags || {}; 10 | data.tags.site = environment; 11 | 12 | if (data.request && data.request.env) { 13 | delete data.request.env; 14 | } 15 | 16 | return data; 17 | } 18 | 19 | function middlewareErrorData(req, data) { 20 | data.extra = data.extra || {}; 21 | data.extra.request_id = req.id; 22 | return data; 23 | } 24 | 25 | function buildRavenClient(dsn, environment) { 26 | var raven_client = new raven.Client(dsn, { 27 | dataCallback: function(data) { 28 | return ravenDataCallback(data, environment); 29 | } 30 | }); 31 | raven_client.patchGlobal(function() { 32 | console.log("Exiting due to uncaught exception."); 33 | process.exit(1); 34 | }); 35 | return raven_client; 36 | } 37 | 38 | function errorStatusCode(err) { 39 | return err.status || 500; 40 | } 41 | 42 | function applySentryMiddleware(app, client) { 43 | app.use(raven.middleware.express(client, middlewareErrorData)); 44 | 45 | app.use(function(err, req, res, next) { 46 | // still useful to have stack in console output for on-server debugging 47 | console.error(err.stack); 48 | res.status(errorStatusCode(err)); 49 | res.send("From Sentry Error Handler"); 50 | res.end("An error occurred.\n" + res.sentry + "\n"); 51 | }); 52 | } 53 | 54 | function simpleOnError(err, req, res, next) { 55 | res.status(errorStatusCode(err)); 56 | res.send("From Simple Error Handler" + "\n" + err.toString()); 57 | res.end(res.sentry + "\n"); 58 | } 59 | 60 | function applySimpleErrorMiddleware(app) { 61 | app.use(simpleOnError); 62 | } 63 | 64 | function handledErrorMiddleware(err, req, res, next) { 65 | if (err.handle) { 66 | err.handle(req, res, next); 67 | } else { 68 | next(err); 69 | } 70 | } 71 | 72 | function applyToApp(app, sentryDSN) { 73 | // Set up error handling 74 | var ravenClient = null; 75 | if (sentryDSN) { 76 | var environment = process.env.INS_STACK_NAME || process.env.NODE_ENV; 77 | ravenClient = buildRavenClient(sentryDSN, environment); 78 | } 79 | 80 | app.use(handledErrorMiddleware); 81 | 82 | if (ravenClient) { 83 | applySentryMiddleware(app, ravenClient); 84 | } else { 85 | applySimpleErrorMiddleware(app); 86 | } 87 | } 88 | 89 | module.exports.applyToApp = applyToApp; 90 | module.exports.onRavenData = ravenDataCallback; 91 | module.exports.onMiddlewareData = middlewareErrorData; 92 | -------------------------------------------------------------------------------- /app/middleware/healthcheck.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | 5 | function defaultResponse() { 6 | return { 7 | components: [], 8 | name: "Rich Content Service", 9 | status: 200, 10 | version: `${getVersion()}` 11 | }; 12 | } 13 | 14 | function defaultFSResponse() { 15 | return { 16 | message: "Filesystem healthy", 17 | name: "Filesystem", 18 | status: 200 19 | }; 20 | } 21 | 22 | function fsHealthy(callback) { 23 | const state = defaultFSResponse(); 24 | 25 | return new Promise(function(resolve, _reject) { 26 | fs.stat("/tmp", (err, stats) => { 27 | if (err || !stats.isDirectory()) { 28 | state.status = 503; 29 | state.message = "Filesystem in unexpected state"; 30 | } 31 | 32 | callback(state); 33 | resolve(); 34 | }); 35 | }); 36 | } 37 | 38 | async function combineTests(tests, callback) { 39 | const response = defaultResponse(); 40 | 41 | for (const test of tests) { 42 | await test(componentState => { 43 | if (componentState.status !== 200) { 44 | response.status = 503; 45 | } 46 | 47 | response.components.push(componentState); 48 | }); 49 | } 50 | 51 | callback(response); 52 | } 53 | 54 | function healthcheck() { 55 | const tests = [fsHealthy]; 56 | 57 | return function(_req, res, _next) { 58 | return new Promise(function(resolve, _reject) { 59 | try { 60 | combineTests(tests, function(response) { 61 | res.status(response.status).json(response); 62 | resolve(); 63 | }); 64 | } catch (err) { 65 | res.status(503).json(err); 66 | resolve(); 67 | } 68 | }); 69 | }; 70 | } 71 | 72 | function getVersion() { 73 | let version = 0; 74 | try { 75 | const fs = require("fs"); 76 | const packageJson = fs.readFileSync("./package.json"); 77 | return JSON.parse(packageJson).version || 0; 78 | } catch (_ex) { 79 | // ignore 80 | } 81 | return version; 82 | } 83 | 84 | module.exports = healthcheck; 85 | -------------------------------------------------------------------------------- /app/middleware/requestLogs.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const morgan = require("morgan"); 4 | 5 | // assumes requests have had id added already 6 | morgan.token("id", function getId(req) { 7 | return req.id; 8 | }); 9 | 10 | function logWithFormat(stream, immediate) { 11 | immediate = immediate || false; 12 | let format = 13 | '[:id] :remote-addr - :remote-user [:date[clf]] ":method :url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'; 14 | return morgan(format, { stream: stream, immediate: immediate }); 15 | } 16 | 17 | function loggingStream() { 18 | if (process.env.NODE_ENV == "test") { 19 | return require("dev-null")(); 20 | } else { 21 | return process.stdout; 22 | } 23 | } 24 | 25 | function applyToApp(app, stream) { 26 | stream = stream || loggingStream(); 27 | let middleware = logWithFormat(stream); 28 | app.use(middleware); 29 | } 30 | 31 | module.exports = { middleware: logWithFormat, applyToApp }; 32 | -------------------------------------------------------------------------------- /app/middleware/stats.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _config = require("../config"); 4 | const StatsD = require("hot-shots"); 5 | 6 | class StatsMiddleware { 7 | static inject() { 8 | return [_config]; 9 | } 10 | 11 | static init(config) { 12 | return new this(config); 13 | } 14 | 15 | static actionKeyMiddleware(controller, action) { 16 | let actionKey = ""; 17 | if (controller == null || action == null) { 18 | actionKey = "__path__"; 19 | } else { 20 | actionKey = `${controller}.${action}`; 21 | } 22 | return (request, response, next) => { 23 | if (actionKey == "__path__") { 24 | actionKey = request.path 25 | .split("/") 26 | .join("-") 27 | .replace(/^-/, ""); 28 | } 29 | request.actionKey = actionKey; 30 | next(); 31 | }; 32 | } 33 | 34 | constructor(config) { 35 | this.tags = config.require("stats.tags"); 36 | this.prefix = config.get("stats.prefix", ""); 37 | this.statsd = new StatsD({ 38 | host: config.get("stats.host"), 39 | port: config.get("stats.port"), 40 | globalTags: typeof this.tags === "object" ? this.tags : {} 41 | }); 42 | this.handle = this.handle.bind(this); 43 | } 44 | 45 | key(name) { 46 | return `${this.prefix}request.${name}`; 47 | } 48 | 49 | timing(long, short, tags, value) { 50 | this._write("timing", long, short, tags, value); 51 | } 52 | 53 | increment(long, short, tags, value = 1) { 54 | this._write("increment", long, short, tags, value); 55 | } 56 | 57 | _write(type, long, short, tags, value) { 58 | if (this.tags !== false) { 59 | // eslint-disable-next-line security/detect-object-injection 60 | this.statsd[type](this.key(short), value, { ...this.tags, ...tags }); 61 | } else { 62 | // eslint-disable-next-line security/detect-object-injection 63 | this.statsd[type](this.key(long), value); 64 | } 65 | } 66 | 67 | handle(req, res, next) { 68 | const start = Date.now(); 69 | req.timers = req.timers || {}; 70 | const send = () => { 71 | const action = req.actionKey; 72 | const status = res.statusCode || "unknown_status"; 73 | this.increment(`${action}.status_code.${status}`, "response", { 74 | action, 75 | status 76 | }); 77 | const duration = Date.now() - start; 78 | this.timing( 79 | `${action}.response_time`, 80 | "response_time", 81 | { action }, 82 | duration 83 | ); 84 | for (const key in req.timers) { 85 | // eslint-disable-next-line security/detect-object-injection 86 | this.timing(`${action}.${key}`, key, { action }, req.timers[key]); 87 | } 88 | cleanup(); 89 | }; 90 | const cleanup = () => { 91 | res.removeListener("finish", send); 92 | res.removeListener("error", cleanup); 93 | res.removeListener("close", cleanup); 94 | }; 95 | res.once("finish", send); 96 | res.once("error", cleanup); 97 | res.once("close", cleanup); 98 | next(); 99 | } 100 | } 101 | 102 | module.exports = StatsMiddleware; 103 | -------------------------------------------------------------------------------- /app/routes.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const stats = require("./middleware/stats"); 4 | const statsdKey = stats.actionKeyMiddleware; 5 | const _api = require("./api"); 6 | const _auth = require("./middleware/auth"); 7 | 8 | function inject() { 9 | return [_api, _auth]; 10 | } 11 | 12 | function init(api, auth) { 13 | return app => { 14 | app.get("/", statsdKey("main", "home"), function(request, response) { 15 | response.send("Hello, from RCE Service"); 16 | }); 17 | 18 | api.applyToApp(app); 19 | 20 | app.get("/test_error", statsdKey("main", "test_error"), function() { 21 | throw new Error("Busted!"); 22 | }); 23 | 24 | app.get("/test_jwt", statsdKey("main", "test_jwt"), auth, function( 25 | request, 26 | response 27 | ) { 28 | response.status(200).end(); 29 | }); 30 | }; 31 | } 32 | 33 | module.exports = { inject, init, singleton: true }; 34 | -------------------------------------------------------------------------------- /app/setup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const withMiddleware = require("./middleware"); 4 | const appRoutes = require("./routes"); 5 | 6 | function setup(app) { 7 | withMiddleware(app, wrappedApp => { 8 | appRoutes(wrappedApp); 9 | }); 10 | } 11 | 12 | module.exports = setup; 13 | -------------------------------------------------------------------------------- /app/token.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const jose = require("node-jose"); 4 | const _config = require("./config"); 5 | const TokenInvalidException = require("./exceptions/TokenInvalidException"); 6 | 7 | class Token { 8 | static inject() { 9 | return [_config]; 10 | } 11 | 12 | static init(config) { 13 | return new this(config); 14 | } 15 | 16 | constructor(config) { 17 | const signingSecret = config.require("token.signingSecret"); 18 | const encryptionSecret = config.require("token.encryptionSecret"); 19 | this._signingKey = this._jwk(signingSecret, "HS256"); 20 | this._encryptionKey = this._jwk(encryptionSecret, "A256GCM"); 21 | } 22 | 23 | /** 24 | * Creates a signed and ecrypted JWT for a given payload. 25 | * 26 | * @param {Object} payload 27 | * 28 | * @return {String} 29 | */ 30 | async create(payload) { 31 | const signed = await this._sign(payload); 32 | const encrypted = await this._encrypt(signed); 33 | const encoded = this._encode(encrypted); 34 | return encoded; 35 | } 36 | 37 | /** 38 | * Returns the payload from a signed and encrypted JWT. Does not validate 39 | * claims. 40 | * 41 | * @param {String} token 42 | * 43 | * @throws {TokenInvalidException} 44 | * 45 | * @return {Object} 46 | */ 47 | async payload(token) { 48 | try { 49 | const decoded = this._decode(token); 50 | const decrypted = await this._decrypt(decoded); 51 | const verified = await this._verify(decrypted.plaintext.toString()); 52 | return JSON.parse(verified.payload); 53 | } catch (error) { 54 | throw TokenInvalidException.parse(error); 55 | } 56 | } 57 | 58 | /** 59 | * Returns the payload from a signed and encrypted JWT and validates the 60 | * claims. 61 | * 62 | * @param {String} token 63 | * 64 | * @throws {TokenInvalidException} 65 | * 66 | * @return {Object} 67 | */ 68 | async verify(token) { 69 | const payload = await this.payload(token); 70 | this._validatePayload(payload); 71 | return payload; 72 | } 73 | 74 | /** 75 | * Wraps and signs a user token. The returned token can be used directly with 76 | * Canvas as a bearer token. 77 | * 78 | * @param {String} token 79 | * 80 | * @return {String} 81 | */ 82 | async wrap(token) { 83 | const payload = { user_token: this._decode(token) }; 84 | const signed = await this._sign(payload); 85 | const encoded = this._encode(signed); 86 | return encoded; 87 | } 88 | 89 | _decode(value) { 90 | return Buffer.from(value, "base64").toString("ascii"); 91 | } 92 | 93 | _encode(value) { 94 | return Buffer.from(value).toString("base64"); 95 | } 96 | 97 | async _decrypt(value) { 98 | const key = await jose.JWK.asKey(this._encryptionKey); 99 | return await jose.JWE.createDecrypt(key).decrypt(value); 100 | } 101 | 102 | async _encrypt(value) { 103 | const key = await jose.JWK.asKey(this._encryptionKey); 104 | return await jose.JWE.createEncrypt({ format: "compact" }, key) 105 | .update(value) 106 | .final(); 107 | } 108 | 109 | async _verify(value) { 110 | const key = await jose.JWK.asKey(this._signingKey); 111 | return await jose.JWS.createVerify(key).verify(value); 112 | } 113 | 114 | async _sign(payload) { 115 | const value = Buffer.from(JSON.stringify(payload)); 116 | const key = await jose.JWK.asKey(this._signingKey); 117 | return await jose.JWS.createSign({ format: "compact" }, key) 118 | .update(value) 119 | .final(); 120 | } 121 | 122 | _validatePayload(payload) { 123 | const now = Date.now() / 1000; 124 | if (payload.nbf != null && payload.nbf > now) { 125 | throw TokenInvalidException.before(); 126 | } 127 | 128 | if (payload.exp != null && payload.exp < now) { 129 | throw TokenInvalidException.expired(); 130 | } 131 | } 132 | 133 | _jwk(secret, alg) { 134 | return { 135 | kty: "oct", 136 | k: Buffer.from(secret).toString("base64"), 137 | alg 138 | }; 139 | } 140 | } 141 | 142 | module.exports = Token; 143 | -------------------------------------------------------------------------------- /app/utils/cipher.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const crypto = require("crypto"); 4 | const algorithm = "aes-256-ctr"; 5 | const password = process.env.CIPHER_PASSWORD; 6 | 7 | function encrypt(text) { 8 | const cipher = crypto.createCipher(algorithm, password); 9 | let crypted = cipher.update(text, "utf8", "hex"); 10 | crypted += cipher.final("hex"); 11 | return crypted; 12 | } 13 | 14 | function decrypt(text) { 15 | const decipher = crypto.createDecipher(algorithm, password); 16 | let dec = decipher.update(text, "hex", "utf8"); 17 | dec += decipher.final("utf8"); 18 | return dec; 19 | } 20 | 21 | module.exports = { encrypt, decrypt }; 22 | -------------------------------------------------------------------------------- /app/utils/fetch.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Translates a fetch-style response into a request-style 4 | // response that the proxied routes expect. Specifically, 5 | // we need to be able to read the body and the headers from 6 | // the same object, which fetch doesn't support by default. 7 | function parseFetchResponse(res) { 8 | return res 9 | .text() 10 | .then(text => { 11 | // Try to parse response body as JSON, if it wasn't JSON 12 | // then default to text representation (including blank). 13 | try { 14 | return JSON.parse(text); 15 | } catch (err) { 16 | return text; 17 | } 18 | }) 19 | .then(data => ({ 20 | body: data, 21 | headers: res.headers.raw(), 22 | statusCode: res.status 23 | })); 24 | } 25 | 26 | module.exports = { parseFetchResponse }; 27 | -------------------------------------------------------------------------------- /app/utils/object.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function getByPath(path, obj) { 4 | const keys = path.split("."); 5 | let next = obj; 6 | for (let key of keys) { 7 | if (typeof next === "object") { 8 | next = next[key]; 9 | } else { 10 | next = null; 11 | } 12 | if (next == null) { 13 | break; 14 | } 15 | } 16 | return next; 17 | } 18 | 19 | function getArrayQueryParam(param) { 20 | let list = ""; 21 | if (param) { 22 | if (Array.isArray(param)) { 23 | list = param; 24 | } else { 25 | list = param.split(","); 26 | } 27 | } 28 | return list; 29 | } 30 | 31 | module.exports = { getByPath, getArrayQueryParam }; 32 | -------------------------------------------------------------------------------- /app/utils/optionalQuery.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function optionalQuery(query, name) { 4 | return query[name] ? `&${name}=${query[name]}` : ""; 5 | } 6 | 7 | module.exports = { optionalQuery }; 8 | -------------------------------------------------------------------------------- /app/utils/search.js: -------------------------------------------------------------------------------- 1 | function getSearch(query) { 2 | const searchTerm = query.search_term || query.searchTerm; 3 | 4 | if (!searchTerm) { 5 | return ""; 6 | } 7 | 8 | let encodedTerm; 9 | try { 10 | isSearchTermEncoded = searchTerm !== decodeURIComponent(searchTerm); 11 | encodedTerm = isSearchTermEncoded 12 | ? searchTerm 13 | : encodeURIComponent(searchTerm); 14 | } catch { 15 | encodedTerm = encodeURIComponent(searchTerm); 16 | } 17 | return `&search_term=${encodedTerm}`; 18 | } 19 | 20 | module.exports = { getSearch }; 21 | -------------------------------------------------------------------------------- /app/utils/sign.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Sign and verify a value. 3 | * 4 | * Implemented as a JWT since the library is already present, and handles 5 | * signing, encoding, and verifying with and expiration. 6 | * 7 | * Uses ECOSYSTEM_SECRET from the environment as the signing secret. 8 | */ 9 | 10 | "use strict"; 11 | 12 | const jwt = require("jsonwebtoken"); 13 | 14 | const DEFAULT_EXP = 24 * 60 * 60; 15 | const SECRET = process.env.ECOSYSTEM_SECRET; 16 | 17 | /** 18 | * Sign a value 19 | * 20 | * @param {*} value to be signed 21 | * @param {number} [expiresIn=86400] seconds 22 | * @return {string} 23 | */ 24 | function sign(val, expiresIn = DEFAULT_EXP) { 25 | return jwt.sign({ val }, SECRET, { expiresIn }); 26 | } 27 | 28 | /** 29 | * Verify and return a signed value 30 | * 31 | * @param {string} token 32 | * @return {*} original value or null if invalid for any reason 33 | */ 34 | function verify(token) { 35 | try { 36 | const { val } = jwt.verify(token, SECRET); 37 | return val; 38 | } catch (_) { 39 | return null; 40 | } 41 | } 42 | 43 | module.exports = { sign, verify }; 44 | -------------------------------------------------------------------------------- /app/utils/sort.js: -------------------------------------------------------------------------------- 1 | function getSort(query) { 2 | const sortBy = query.sort; 3 | const orderBy = query.order; 4 | 5 | if (!sortBy) { 6 | return ""; 7 | } 8 | 9 | return orderBy ? `&sort=${sortBy}&order=${orderBy}` : `&sort=${sortBy}`; 10 | } 11 | 12 | module.exports = { getSort }; 13 | -------------------------------------------------------------------------------- /bin/slack_notify: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | type="$1" 3 | 4 | broadcast_notification() { 5 | local message="$1" 6 | local room="$2" 7 | local data="{\"username\":\"DeployBot\",\"channel\":\"$room\",\"icon_emoji\":\":shipit:\",\"text\":\"$message\"}" 8 | curl -X POST -H 'Content-type: application/json' --data "$data" $SLACK_HOOK_URL 9 | } 10 | 11 | if [[ -z $SLACK_HOOK_URL ]] 12 | then 13 | echo "SLACK_HOOK_URL not set, post to #changelog yourself" 14 | else 15 | if [[ "$type" == "pre" ]] 16 | then 17 | broadcast_notification "about to deploy RCE service $CG_GIT_COMMIT_ID to $AWS_REGION $CG_ENVIRONMENT" "#canvas_deploys" 18 | elif [[ "$type" == "post" ]] 19 | then 20 | broadcast_notification "RCE service $CG_GIT_COMMIT_ID deployed successfully to $AWS_REGION $CG_ENVIRONMENT" "#canvas_deploys" 21 | if [[ "$AWS_REGION" == "us-east-1" ]] 22 | then 23 | broadcast_notification "Deployed Rich Content Service $CG_ENVIRONMENT revision $CG_GIT_COMMIT_ID to $AWS_REGION" "#changelog" 24 | fi 25 | else 26 | echo "I don't know how to send a notification of type $type" 27 | fi 28 | fi 29 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export COMPOSE_FILE=./docker-compose.yml 3 | 4 | # build the container 5 | docker-compose build 6 | 7 | # start the containers 8 | docker-compose up -d 9 | 10 | # run dependency scanner 11 | docker-compose run --rm -e SNYK_TOKEN=$SNYK_TOKEN web npm run security:dependency-monitor 12 | 13 | # run unit tests 14 | docker-compose exec -T web npm run test-cov 15 | unit_status=$? 16 | docker cp $(docker-compose ps -q web):/usr/src/app/coverage coverage 17 | 18 | # check formatting 19 | docker-compose run --rm web npm run fmt:check 20 | fmt_status=$? 21 | 22 | # lint all the things 23 | docker-compose run --rm web npm run lint 24 | lint_status=$? 25 | 26 | docker-compose stop 27 | 28 | # jenkins uses the exit code to decide whether you passed or not 29 | ((unit_status)) && exit $unit_status 30 | ((fmt_status)) && exit $fmt_status 31 | ((lint_status)) && exit $lint_status 32 | exit 0 33 | -------------------------------------------------------------------------------- /config/app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = env => ({ 4 | environment: env.get("NODE_ENV", () => "development") 5 | }); 6 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | app: require("./app"), 5 | token: require("./token"), 6 | stats: require("./stats") 7 | }; 8 | -------------------------------------------------------------------------------- /config/stats.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = env => ({ 4 | host: env.require("STATSD_HOST"), 5 | port: env.require("STATSD_PORT"), 6 | tags: JSON.parse(env.get("DOG_TAGS", () => "false")), 7 | prefix: `${env.get("STATS_PREFIX", () => "rce-api")}.${env.get( 8 | "CG_ENVIRONMENT", 9 | () => "dev" 10 | )}.` 11 | }); 12 | -------------------------------------------------------------------------------- /config/token.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = env => ({ 4 | encryptionSecret: env.require("ECOSYSTEM_KEY"), 5 | signingSecret: env.require("ECOSYSTEM_SECRET") 6 | }); 7 | -------------------------------------------------------------------------------- /docker-compose.override.yml.dev: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | web: 4 | volumes: 5 | - .:/usr/src/app 6 | command: 7 | - npm 8 | - run 9 | - start:dev 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | web: 4 | build: . 5 | environment: 6 | PORT: 80 7 | VIRTUAL_HOST: rce.docker 8 | NODE_ENV: development 9 | STATSD_HOST: 127.0.0.1 10 | STATSD_PORT: 8125 11 | ECOSYSTEM_SECRET: "astringthatisactually32byteslong" 12 | ECOSYSTEM_KEY: "astringthatisactually32byteslong" 13 | CIPHER_PASSWORD: TEMP_PASSWORD 14 | # HTTP_PROTOCOL_OVERRIDE: http 15 | -------------------------------------------------------------------------------- /inst-cli/docker-compose/docker-compose.local.dev.yml: -------------------------------------------------------------------------------- 1 | name: canvas-rce-api 2 | 3 | services: 4 | web: 5 | container_name: canvas-rce-api-web 6 | build: . 7 | environment: 8 | PORT: 80 9 | NODE_ENV: development 10 | STATSD_HOST: 127.0.0.1 11 | STATSD_PORT: 8125 12 | ECOSYSTEM_SECRET: "astringthatisactually32byteslong" 13 | ECOSYSTEM_KEY: "astringthatisactually32byteslong" 14 | CIPHER_PASSWORD: TEMP_PASSWORD 15 | # HTTP_PROTOCOL_OVERRIDE: http 16 | labels: 17 | - traefik.enable=true 18 | - traefik.http.middlewares.rce-cors.headers.accesscontrolallowmethods=GET,POST,OPTIONS,PUT,PATCH 19 | - traefik.http.middlewares.rce-cors.headers.accesscontrolallowheaders=* 20 | - traefik.http.middlewares.rce-cors.headers.accesscontrolalloworiginlist=* 21 | - traefik.http.middlewares.rce-cors.headers.accesscontrolmaxage=100 22 | - traefik.http.middlewares.rce-cors.headers.addvaryheader=true 23 | networks: 24 | default: 25 | aliases: 26 | - canvas-rce-api-web 27 | - canvas-rce-api-web.$INST_DOMAIN 28 | volumes: 29 | - .:/usr/src/app 30 | command: 31 | - npm 32 | - run 33 | - start:dev 34 | networks: 35 | default: 36 | external: true 37 | name: "inst_shared" 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "canvas-rce-api", 3 | "version": "1.27.3", 4 | "description": "An API for proxying requests by the RCE to Canvas and other services.", 5 | "engines": { 6 | "node": "^18", 7 | "npm": "^9" 8 | }, 9 | "mocha": { 10 | "exit": true, 11 | "require": [ 12 | "dotenv/config", 13 | "test/support/setup" 14 | ] 15 | }, 16 | "nyc": { 17 | "include": [ 18 | "app/**/*.js", 19 | "shared/**/*.js" 20 | ] 21 | }, 22 | "eslintConfig": { 23 | "parserOptions": { 24 | "ecmaVersion": 2018 25 | }, 26 | "env": { 27 | "es6": true, 28 | "node": true, 29 | "mocha": true 30 | }, 31 | "extends": [ 32 | "eslint:recommended", 33 | "plugin:security/recommended", 34 | "prettier" 35 | ], 36 | "plugins": [ 37 | "mocha", 38 | "security" 39 | ], 40 | "rules": { 41 | "strict": 0, 42 | "mocha/no-exclusive-tests": 2, 43 | "mocha/handle-done-callback": 2, 44 | "mocha/no-global-tests": 2 45 | } 46 | }, 47 | "main": "app.js", 48 | "config": { 49 | "mocha_env": "test" 50 | }, 51 | "scripts": { 52 | "lint": "eslint \"app/**/*.js\" \"test/**/*.js\" \"shared/**/*.js\"", 53 | "test": "mocha 'test/{service,shared}/**/*.test.js'", 54 | "test:one": "mocha", 55 | "test-cov": "cross-env BABEL_ENV=test nyc -r html node_modules/.bin/mocha -- 'test/{service,shared}/**/*.test.js'", 56 | "debug-test": "mocha --inspect-brk 'test/{service,shared}/**/*.test.js'", 57 | "debug-test:one": "mocha --inspect-brk", 58 | "fmt:check": "prettier -l '**/*.{js,json}'", 59 | "fmt:fix": "prettier --write '**/*.{js,json}'", 60 | "precommit": "pretty-quick --staged", 61 | "start": "node app.js", 62 | "start:dev": "nodemon --watch app --watch config app.js", 63 | "start:debug": "node --inspect-brk app.js", 64 | "security:dependency-monitor": "snyk monitor" 65 | }, 66 | "author": "Instructure, Inc.", 67 | "private": true, 68 | "license": "MIT", 69 | "dependencies": { 70 | "body-parser": "^1.18.3", 71 | "cors": "^2.8.5", 72 | "dotenv": "^5.0.1", 73 | "entities": "^1.1.2", 74 | "escape-html": "^1.0.3", 75 | "express": "^4.16.3", 76 | "express-request-id": "^1.4.1", 77 | "express-statsd": "^0.3.0", 78 | "hot-shots": "^6.1.1", 79 | "inja": "^1.1.0", 80 | "jsonwebtoken": "^5.7.0", 81 | "moment": "^2.22.2", 82 | "morgan": "^1.10.0", 83 | "node-exceptions": "^3.0.0", 84 | "node-fetch": "^2.6.1", 85 | "node-jose": "^2.0.0", 86 | "parse-link-header": "^0.4.1", 87 | "qs": "^6.2.1", 88 | "querystring": "^0.2.1", 89 | "raven": "github:zwily/raven-node#middleware-req-callback", 90 | "url": "^0.11.3" 91 | }, 92 | "devDependencies": { 93 | "cross-env": "^3.1.3", 94 | "dev-null": "0.1.1", 95 | "eslint": "^4.19.1", 96 | "eslint-config-prettier": "^6", 97 | "eslint-plugin-mocha": "4.12.1", 98 | "eslint-plugin-security": "^1.4.0", 99 | "get-port": "^5.0.0", 100 | "husky": "^0.14.3", 101 | "mocha": "^8.2.1", 102 | "nock": "^13", 103 | "nodemon": "^1.18.3", 104 | "nyc": "^12.0.2", 105 | "prettier": "^1", 106 | "pretty-quick": "^1.4.1", 107 | "sinon": "1.17.3", 108 | "snyk": "^1.99.1", 109 | "supertest": "^3.1.0" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /shared/mimeClass.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | function fileEmbed(file) { 4 | let fileMimeClass = mimeClass(file); 5 | let fileMediaEntryId = mediaEntryId(file); 6 | 7 | if (fileMimeClass === "image") { 8 | return { type: "image" }; 9 | } else if (file.preview_url) { 10 | return { type: "scribd" }; 11 | } else if (fileMimeClass === "video") { 12 | return { type: "video", id: fileMediaEntryId }; 13 | } else if (fileMimeClass === "audio") { 14 | return { type: "audio", id: fileMediaEntryId }; 15 | } else { 16 | return { type: "file" }; 17 | } 18 | } 19 | 20 | function mediaEntryId(file) { 21 | return file.media_entry_id || "maybe"; 22 | } 23 | 24 | function mimeClass(file) { 25 | if (file.mime_class) { 26 | return file.mime_class; 27 | } else { 28 | let contentType = getContentType(file); 29 | 30 | return ( 31 | { 32 | "text/html": "html", 33 | "text/x-csharp": "code", 34 | "text/xml": "code", 35 | "text/css": "code", 36 | text: "text", 37 | "text/plain": "text", 38 | "application/rtf": "doc", 39 | "text/rtf": "doc", 40 | "application/vnd.oasis.opendocument.text": "doc", 41 | "application/pdf": "pdf", 42 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document": 43 | "doc", 44 | "application/vnd.apple.pages": "doc", 45 | "application/x-docx": "doc", 46 | "application/msword": "doc", 47 | "application/vnd.ms-powerpoint": "ppt", 48 | "application/vnd.openxmlformats-officedocument.presentationml.presentation": 49 | "ppt", 50 | "applicatoin/vnd.apple.key": "ppt", 51 | "application/vnd.ms-excel": "xls", 52 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": 53 | "xls", 54 | "application/vnd.apple.numbers": "xls", 55 | "application/vnd.oasis.opendocument.spreadsheet": "xls", 56 | "image/jpeg": "image", 57 | "image/pjpeg": "image", 58 | "image/png": "image", 59 | "image/gif": "image", 60 | "image/bmp": "image", 61 | "image/svg+xml": "image", 62 | "image/webp": "image", 63 | "application/x-rar": "zip", 64 | "application/x-rar-compressed": "zip", 65 | "application/x-zip": "zip", 66 | "application/x-zip-compressed": "zip", 67 | "application/xml": "code", 68 | "application/zip": "zip", 69 | "audio/mp3": "audio", 70 | "audio/mpeg": "audio", 71 | "audio/basic": "audio", 72 | "audio/mid": "audio", 73 | "audio/3gpp": "audio", 74 | "audio/x-aiff": "audio", 75 | "audio/x-m4a": "audio", 76 | "audio/x-mpegurl": "audio", 77 | "audio/x-ms-wma": "audio", 78 | "audio/x-pn-realaudio": "audio", 79 | "audio/x-wav": "audio", 80 | "audio/mp4": "audio", 81 | "audio/wav": "audio", 82 | "audio/webm": "audio", 83 | "audio/*": "audio", 84 | audio: "audio", 85 | "video/mpeg": "video", 86 | "video/quicktime": "video", 87 | "video/x-la-asf": "video", 88 | "video/x-ms-asf": "video", 89 | "video/x-ms-wma": "audio", 90 | "video/x-ms-wmv": "video", 91 | "video/x-msvideo": "video", 92 | "video/x-sgi-movie": "video", 93 | "video/3gpp": "video", 94 | "video/mp4": "video", 95 | "video/webm": "video", 96 | "video/avi": "video", 97 | "video/*": "video", 98 | video: "video", 99 | "application/x-shockwave-flash": "flash" 100 | }[contentType] || "file" 101 | ); 102 | } 103 | } 104 | 105 | function getContentType(file) { 106 | return file["content-type"] || file.type; 107 | } 108 | 109 | module.exports = { fileEmbed, mimeClass }; 110 | -------------------------------------------------------------------------------- /test/service/api/announcements.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const announcements = require("../../../app/api/announcements"); 6 | 7 | describe("Announcements API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let query; 11 | beforeEach(() => { 12 | query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | }); 14 | 15 | it("builds course paths", () => { 16 | const path = announcements.canvasPath({ query }); 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | const path = announcements.canvasPath({ query }); 22 | assert.ok(path.match("courses/123")); 23 | }); 24 | 25 | it("asks for discussions (announcements are discussions)", () => { 26 | const path = announcements.canvasPath({ query }); 27 | assert.ok(path.match("discussion_topics")); 28 | }); 29 | 30 | it("restricts to announcements", () => { 31 | const path = announcements.canvasPath({ query }); 32 | assert.ok(path.match("only_announcements=1")); 33 | }); 34 | 35 | it("passes per_page through", () => { 36 | const path = announcements.canvasPath({ query }); 37 | assert.ok(path.match("per_page=50")); 38 | }); 39 | 40 | it("includes search term", () => { 41 | query.search_term = "hello"; 42 | const path = announcements.canvasPath({ query }); 43 | assert.ok(path.match("&search_term=hello")); 44 | }); 45 | }); 46 | 47 | describe("group context", () => { 48 | let path; 49 | beforeEach(() => { 50 | const query = { contextType: "group", contextId: 456, per_page: 50 }; 51 | path = announcements.canvasPath({ query }); 52 | }); 53 | 54 | it("builds group paths", () => { 55 | assert.ok(path.match("api/v1/groups")); 56 | }); 57 | 58 | it("uses context id in path", () => { 59 | assert.ok(path.match("groups/456")); 60 | }); 61 | 62 | it("passes per_page through", () => { 63 | assert.ok(path.match("per_page=50")); 64 | }); 65 | }); 66 | 67 | it("throws on user context", () => { 68 | const query = { contextType: "user", contextId: "self" }; 69 | assert.throws(() => announcements.canvasPath({ query })); 70 | }); 71 | }); 72 | 73 | describe("canvasResponseHandler", () => { 74 | const request = {}; 75 | const response = { status: () => {}, send: () => {} }; 76 | 77 | function setup(statusCode = 200, overrides = {}) { 78 | const canvasResponse = { 79 | statusCode: statusCode, 80 | body: [ 81 | { 82 | html_url: "/courses/1/announcements/2", 83 | title: "Announcement 2", 84 | posted_at: "2019-04-24T13:00:00Z", 85 | ...overrides 86 | } 87 | ] 88 | }; 89 | sinon.spy(response, "send"); 90 | announcements.canvasResponseHandler(request, response, canvasResponse); 91 | const result = response.send.firstCall.args[0]; 92 | response.send.restore(); 93 | return [result, canvasResponse]; 94 | } 95 | 96 | it("pulls href from canvas' html_url", () => { 97 | const [result, canvasResponse] = setup(); 98 | assert.equal(result.links[0].href, canvasResponse.body[0].html_url); 99 | }); 100 | 101 | it("pulls title from canvas' title", () => { 102 | const [result, canvasResponse] = setup(); 103 | assert.equal(result.links[0].title, canvasResponse.body[0].title); 104 | }); 105 | 106 | it("pulls posted_at from canvas' response", () => { 107 | const [result, canvasResponse] = setup(); 108 | assert.equal(result.links[0].date, canvasResponse.body[0].posted_at); 109 | assert.equal(result.links[0].date_type, "posted"); 110 | }); 111 | 112 | it("pulls delayed_post_at from canvas' response if after posted_at", () => { 113 | const posted_at = "2019-04-25T13:00:00Z"; 114 | const delayed_post_at = "2019-05-05T13:00:00Z"; 115 | const [result, canvasResponse] = setup(200, { 116 | posted_at, 117 | delayed_post_at 118 | }); 119 | assert.equal( 120 | result.links[0].date, 121 | canvasResponse.body[0].delayed_post_at 122 | ); 123 | assert.equal(result.links[0].date_type, "delayed_post"); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/service/api/assignments.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const assignments = require("../../../app/api/assignments"); 6 | 7 | describe("Assignments API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let query; 11 | beforeEach(() => { 12 | query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | }); 14 | 15 | it("builds course paths", () => { 16 | const path = assignments.canvasPath({ query }); 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | const path = assignments.canvasPath({ query }); 22 | assert.ok(path.match("courses/123")); 23 | }); 24 | 25 | it("asks for assignments", () => { 26 | const path = assignments.canvasPath({ query }); 27 | assert.ok(path.match("assignments")); 28 | }); 29 | 30 | it("passes per_page through", () => { 31 | const path = assignments.canvasPath({ query }); 32 | assert.ok(path.match("per_page=50")); 33 | }); 34 | 35 | it("includes search term", () => { 36 | query.search_term = "hello"; 37 | const path = assignments.canvasPath({ query }); 38 | assert.ok(path.match("&search_term=hello")); 39 | }); 40 | }); 41 | 42 | it("throws on group context", () => { 43 | const query = { contextType: "group", contextId: "456" }; 44 | assert.throws(() => assignments.canvasPath({ query })); 45 | }); 46 | 47 | it("throws on user context", () => { 48 | const query = { contextType: "user", contextId: "self" }; 49 | assert.throws(() => assignments.canvasPath({ query })); 50 | }); 51 | }); 52 | 53 | describe("canvasResponseHandler", () => { 54 | const request = {}; 55 | const response = { status: () => {}, send: () => {} }; 56 | 57 | function setup(statusCode = 200, overrides = {}) { 58 | const canvasResponse = { 59 | statusCode: statusCode, 60 | body: [ 61 | { 62 | html_url: "/courses/1/assignments/2", 63 | name: "Assignment 2", 64 | due_date: "2019-04-22T13:00:00Z", 65 | date_type: "due", 66 | published: true, 67 | ...overrides 68 | } 69 | ] 70 | }; 71 | sinon.spy(response, "send"); 72 | assignments.canvasResponseHandler(request, response, canvasResponse); 73 | const result = response.send.firstCall.args[0]; 74 | response.send.restore(); 75 | return [result, canvasResponse]; 76 | } 77 | 78 | it("pulls href from canvas' html_url", () => { 79 | const [result, canvasResponse] = setup(); 80 | assert.strictEqual(result.links[0].href, canvasResponse.body[0].html_url); 81 | }); 82 | 83 | it("pulls title from canvas' name", () => { 84 | const [result, canvasResponse] = setup(); 85 | assert.strictEqual(result.links[0].title, canvasResponse.body[0].name); 86 | }); 87 | 88 | it("pulls the published state from canvas' response", () => { 89 | const [result, canvasResponse] = setup(); 90 | assert.strictEqual( 91 | result.links[0].published, 92 | canvasResponse.body[0].published 93 | ); 94 | }); 95 | 96 | it("pulls date from canvas' due_at", () => { 97 | const [result, canvasResponse] = setup(); 98 | assert.strictEqual(result.links[0].date, canvasResponse.body[0].due_at); 99 | assert.strictEqual(result.links[0].date_type, "due"); 100 | }); 101 | 102 | it("deals with multiple dates", () => { 103 | const [result] = setup(200, { has_overrides: true }); 104 | assert.strictEqual(result.links[0].date, "multiple"); 105 | assert.strictEqual(result.links[0].date_type, "due"); 106 | }); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/service/api/canvasProxy.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const canvasProxy = require("../../../app/api/canvasProxy"); 4 | const assert = require("assert"); 5 | const nock = require("nock"); 6 | const sinon = require("sinon"); 7 | const sign = require("../../../app/utils/sign"); 8 | 9 | describe("Canvas Proxy", () => { 10 | let httpStub; 11 | let token = "token"; 12 | let request; 13 | let reqId = "1234-5678-9012-3456"; 14 | let encodedReqId = Buffer.from(reqId).toString("base64"); 15 | let idSignature = 16 | "18cGMHRzi2nQG6Fg1ye+X8VC7Pk0x5njI9hEdhwwcKLKcFhoFNjYmo4i+hFiMdkh21pLLmuMc7AYUVk9NDKSfQ=="; 17 | let host = "some.instructure.com"; 18 | let path = "/api/v1/some/path"; 19 | let url = `http://${host}${path}`; 20 | 21 | before(() => { 22 | request = { id: reqId, get: () => "Special Agent" }; 23 | }); 24 | 25 | beforeEach(() => { 26 | process.env.ECOSYSTEM_SECRET = "testsecret"; 27 | httpStub = nock("http://" + host); 28 | }); 29 | 30 | describe("fetch", () => { 31 | it("requests from given url", async () => { 32 | var scope = httpStub.get(path).reply(200); 33 | await canvasProxy.fetch(url, request, token); 34 | assert.ok(scope.isDone()); 35 | }); 36 | 37 | it("passes the response back through to the caller", async () => { 38 | httpStub.get(path).reply(200, { some: "data" }); 39 | const response = await canvasProxy.fetch(url, request, token); 40 | assert.strictEqual(response.body.some, "data"); 41 | }); 42 | 43 | it("passes the token along in the auth header", async () => { 44 | var scope = httpStub 45 | .matchHeader("Authorization", "Bearer token") 46 | .get(path) 47 | .reply(200); 48 | 49 | await canvasProxy.fetch(url, request, token); 50 | assert.ok(scope.isDone()); 51 | }); 52 | 53 | it("provides the request ID in the context id header", async () => { 54 | let scope = httpStub 55 | .matchHeader("X-Request-Context-Id", encodedReqId) 56 | .get(path) 57 | .reply(200); 58 | 59 | await canvasProxy.fetch(url, request, token); 60 | assert.ok(scope.isDone()); 61 | }); 62 | 63 | it("signs the request id to confirm origin", async () => { 64 | let scope = httpStub 65 | .matchHeader("X-Request-Context-Signature", idSignature) 66 | .get(path) 67 | .reply(200); 68 | 69 | await canvasProxy.fetch(url, request, token); 70 | assert.ok(scope.isDone()); 71 | }); 72 | 73 | it("passes the request user agent through", async () => { 74 | let scope = httpStub 75 | .matchHeader("User-Agent", "Special Agent") 76 | .get(path) 77 | .reply(200); 78 | 79 | await canvasProxy.fetch(url, request, token); 80 | assert.ok(scope.isDone()); 81 | }); 82 | 83 | it("writes stats to track canvas time", async () => { 84 | httpStub.get(path).reply(200); 85 | await canvasProxy.fetch(url, request, token); 86 | assert("canvas_time" in request.timers); 87 | }); 88 | 89 | describe("bookmark extraction", () => { 90 | const bookmark = "bookmarkValue"; 91 | 92 | it("signs bookmark from link header and includes in response", async () => { 93 | const mock = sinon.mock(sign); 94 | const signed = "signed"; 95 | mock 96 | .expects("sign") 97 | .once() 98 | .withExactArgs(bookmark) 99 | .returns(signed); 100 | httpStub 101 | .get(path) 102 | .reply(200, { some: "data" }, { Link: `<${bookmark}>; rel="next"` }); 103 | const response = await canvasProxy.fetch(url, request, token); 104 | assert.strictEqual(response.bookmark, signed); 105 | mock.verify(); 106 | }); 107 | 108 | it('only cares about the rel="next" link in the header', async () => { 109 | httpStub 110 | .get(path) 111 | .reply(200, { some: "data" }, { Link: `<${bookmark}>; rel="prev"` }); 112 | const response = await canvasProxy.fetch(url, request, token); 113 | assert.strictEqual(response.bookmark, undefined); 114 | }); 115 | 116 | it("skips if there is no Link header", async () => { 117 | httpStub.get(path).reply(200, { some: "data" }); 118 | const response = await canvasProxy.fetch(url, request, token); 119 | assert.strictEqual(response.bookmark, undefined); 120 | }); 121 | }); 122 | }); 123 | 124 | describe("send", () => { 125 | it("hits the given url with the string body for a post", async () => { 126 | const postBody = "this is a string"; 127 | var scope = httpStub.post(path, postBody).reply(200, "{}"); 128 | await canvasProxy.send("POST", url, request, token, postBody); 129 | assert.ok(scope.isDone()); 130 | }); 131 | 132 | it("hits the given url with the string body for a put", async () => { 133 | const postBody = "this is a string"; 134 | var scope = httpStub.put(path, postBody).reply(200, "{}"); 135 | await canvasProxy.send("PUT", url, request, token, postBody); 136 | assert.ok(scope.isDone()); 137 | }); 138 | it("hits the given url with the object body for a post", async () => { 139 | const postBody = { foo: 1, bar: 2 }; 140 | var scope = httpStub.post(path, postBody).reply(200, "{}"); 141 | await canvasProxy.send("POST", url, request, token, postBody); 142 | assert.ok(scope.isDone()); 143 | }); 144 | 145 | it("hits the given url with the body for a put", async () => { 146 | const postBody = { foo: 1, bar: 2 }; 147 | var scope = httpStub.put(path, postBody).reply(200, "{}"); 148 | await canvasProxy.send("PUT", url, request, token, postBody); 149 | assert.ok(scope.isDone()); 150 | }); 151 | 152 | it("passes the token and request id along in the headers", async () => { 153 | var scope = httpStub 154 | .matchHeader("Authorization", "Bearer token") 155 | .matchHeader("X-Request-Context-Id", encodedReqId) 156 | .matchHeader("X-Request-Context-Signature", idSignature) 157 | .post(path) 158 | .reply(200, "{}"); 159 | 160 | await canvasProxy.send("POST", url, request, token); 161 | assert.ok(scope.isDone()); 162 | }); 163 | 164 | it("writes a stats key for posts", async () => { 165 | const postBody = "post body"; 166 | httpStub.post(path, postBody).reply(200, "{}"); 167 | await canvasProxy.send("POST", url, request, token, postBody); 168 | assert("canvas_time" in request.timers); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /test/service/api/discussions.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const discussions = require("../../../app/api/discussions"); 6 | 7 | describe("Discussions API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let query; 11 | beforeEach(() => { 12 | query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | }); 14 | 15 | it("builds course paths", () => { 16 | const path = discussions.canvasPath({ query }); 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | const path = discussions.canvasPath({ query }); 22 | assert.ok(path.match("courses/123")); 23 | }); 24 | 25 | it("asks for discussions", () => { 26 | const path = discussions.canvasPath({ query }); 27 | assert.ok(path.match("discussion_topics")); 28 | }); 29 | 30 | it("passes per_page through", () => { 31 | const path = discussions.canvasPath({ query }); 32 | assert.ok(path.match("per_page=50")); 33 | }); 34 | 35 | it("includes search term", () => { 36 | query.search_term = "hello"; 37 | const path = discussions.canvasPath({ query }); 38 | assert.ok(path.match("&search_term=hello")); 39 | }); 40 | }); 41 | 42 | describe("group context", () => { 43 | let path; 44 | beforeEach(() => { 45 | const query = { contextType: "group", contextId: 456 }; 46 | path = discussions.canvasPath({ query }); 47 | }); 48 | 49 | it("builds group paths", () => { 50 | assert.ok(path.match("api/v1/groups")); 51 | }); 52 | 53 | it("uses context id in path", () => { 54 | assert.ok(path.match("groups/456")); 55 | }); 56 | }); 57 | 58 | it("throws on user context", () => { 59 | const query = { contextType: "user", contextId: "self" }; 60 | assert.throws(() => discussions.canvasPath({ query })); 61 | }); 62 | }); 63 | 64 | describe("canvasResponseHandler", () => { 65 | const request = {}; 66 | const response = { status: () => {}, send: () => {} }; 67 | 68 | function setup(statusCode = 200, overrides = {}) { 69 | const canvasResponse = { 70 | statusCode: statusCode, 71 | body: [ 72 | { 73 | html_url: "/courses/1/discussions/2", 74 | title: "Discussion 2", 75 | published: true, 76 | ...overrides 77 | } 78 | ] 79 | }; 80 | sinon.spy(response, "send"); 81 | discussions.canvasResponseHandler(request, response, canvasResponse); 82 | const result = response.send.firstCall.args[0]; 83 | response.send.restore(); 84 | return [result, canvasResponse]; 85 | } 86 | 87 | it("pulls href from canvas' html_url", () => { 88 | const [result, canvasResponse] = setup(); 89 | assert.equal(result.links[0].href, canvasResponse.body[0].html_url); 90 | }); 91 | 92 | it("pulls title from canvas' title", () => { 93 | const [result, canvasResponse] = setup(); 94 | assert.equal(result.links[0].title, canvasResponse.body[0].title); 95 | }); 96 | 97 | it("pulls the published state from canvas' response", () => { 98 | const [result, canvasResponse] = setup(); 99 | assert.equal(result.links[0].published, canvasResponse.body[0].published); 100 | }); 101 | 102 | it("pulls the due_at date from canvas' response", () => { 103 | const [result, canvasResponse] = setup(200, { 104 | assignment: { due_at: "2019-04-22T13:00:00Z" } 105 | }); 106 | assert.equal( 107 | result.links[0].date, 108 | canvasResponse.body[0].assignment.due_at 109 | ); 110 | assert.equal(result.links[0].date_type, "due"); 111 | }); 112 | 113 | it("pulls the todo date from canvas' response", () => { 114 | const [result, canvasResponse] = setup(200, { 115 | todo_date: "2019-04-22T13:00:00Z" 116 | }); 117 | assert.equal(result.links[0].date, canvasResponse.body[0].todo_date); 118 | assert.equal(result.links[0].date_type, "todo"); 119 | }); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /test/service/api/file.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const { canvasPath, canvasResponseHandler } = require("../../../app/api/file"); 6 | 7 | describe("File API", () => { 8 | describe("canvasPath()", () => { 9 | const id = 47; 10 | const params = { fileId: id }; 11 | 12 | it("builds the correct path including file id", () => { 13 | const query = { per_page: 50 }; 14 | const expectedPath = `/api/v1/files/${id}?include=preview_url`; 15 | assert.equal(canvasPath({ params, query }), expectedPath); 16 | }); 17 | 18 | describe("when query params are given", () => { 19 | const query = { 20 | replacement_chain_context_type: "course", 21 | replacement_chain_context_id: 2, 22 | include: "blueprint_course_status" 23 | }; 24 | 25 | it("includes the replacement context params in the query string", () => { 26 | const expectedPath = `/api/v1/files/47?replacement_chain_context_type=course&replacement_chain_context_id=2&include=preview_url&include=blueprint_course_status`; 27 | assert.equal(canvasPath({ params, query }), expectedPath); 28 | }); 29 | }); 30 | }); 31 | 32 | describe("canvasResponseHandler()", () => { 33 | let request, response, canvasResponse; 34 | 35 | beforeEach(() => { 36 | request = {}; 37 | response = { 38 | status: sinon.spy(), 39 | send: sinon.spy() 40 | }; 41 | canvasResponse = { 42 | status: 200, 43 | body: [] 44 | }; 45 | }); 46 | 47 | it("sends status from canvasResponse", () => { 48 | canvasResponseHandler(request, response, canvasResponse); 49 | response.status.calledWith(canvasResponse.status); 50 | }); 51 | 52 | it("sends body from canvasResponse for non-200 responses", () => { 53 | canvasResponse.status = 400; 54 | canvasResponseHandler(request, response, canvasResponse); 55 | response.send.calledWith(canvasResponse.body); 56 | }); 57 | 58 | describe("transformed response body", () => { 59 | let file = null; 60 | 61 | beforeEach(() => { 62 | file = { 63 | id: 47, 64 | "content-type": "text/plain", 65 | display_name: "Foo", 66 | filename: "Foo.pdf", 67 | preview_url: "someCANVADOCSurl", 68 | url: "someurl", 69 | restricted_by_master_course: "true", 70 | is_master_course_child_content: "true" 71 | }; 72 | }); 73 | 74 | it("file has correctly tranformed properties", () => { 75 | canvasResponse.body = [file]; 76 | canvasResponseHandler(request, response, canvasResponse); 77 | response.send.calledWithMatch(val => { 78 | return sinon.match( 79 | { 80 | id: file.id, 81 | type: file["content-type"], 82 | name: file.display_name, 83 | url: file.url, 84 | preview_url: file.preview_url, 85 | embed: { type: "file" }, 86 | restricted_by_master_course: "true" 87 | }, 88 | val 89 | ); 90 | }); 91 | }); 92 | 93 | it("will use fallbacks for name", () => { 94 | file.display_name = undefined; 95 | canvasResponse.body = [file]; 96 | canvasResponseHandler(request, response, canvasResponse); 97 | response.send.calledWithMatch(val => { 98 | return sinon.match({ name: file.filename }, val); 99 | }); 100 | }); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/service/api/files.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const { canvasPath, canvasResponseHandler } = require("../../../app/api/files"); 6 | 7 | describe("Files API", () => { 8 | describe("canvasPath()", () => { 9 | describe("files in specific folder", () => { 10 | it("builds the correct path including folder id", () => { 11 | const id = 47; 12 | const params = { folderId: id }; 13 | const query = { 14 | contextType: "course", 15 | contextId: "nomatter", 16 | per_page: 50 17 | }; 18 | const expectedPath = `/api/v1/folders/${id}/files?per_page=50&include[]=preview_url&use_verifiers=0`; 19 | assert.equal(canvasPath({ params, query }), expectedPath); 20 | }); 21 | 22 | it("builds the correct path for the user context", () => { 23 | const params = {}; 24 | const query = { contextType: "user", contextId: "17", per_page: 50 }; 25 | const expectedPath = `/api/v1/users/${query.contextId}/files?per_page=50&include[]=preview_url&use_verifiers=0`; 26 | assert.equal(canvasPath({ params, query }), expectedPath); 27 | }); 28 | 29 | it("builds the correct path with search_term present", () => { 30 | const id = 47; 31 | const params = { folderId: id }; 32 | const query = { 33 | contextType: "course", 34 | contextId: "nomatter", 35 | per_page: 50, 36 | search_term: "banana" 37 | }; 38 | const expectedPath = `/api/v1/folders/${id}/files?per_page=50&include[]=preview_url&use_verifiers=0&search_term=banana`; 39 | assert.equal(canvasPath({ params, query }), expectedPath); 40 | }); 41 | 42 | it("builds the correct path with sort and order present", () => { 43 | const id = 47; 44 | const params = { folderId: id }; 45 | const query = { 46 | contextType: "course", 47 | contextId: "nomatter", 48 | per_page: 50, 49 | sort: "created_at", 50 | order: "desc" 51 | }; 52 | const expectedPath = `/api/v1/folders/${id}/files?per_page=50&include[]=preview_url&use_verifiers=0&sort=created_at&order=desc`; 53 | assert.equal(canvasPath({ params, query }), expectedPath); 54 | }); 55 | 56 | describe("when a category is given", () => { 57 | let params, query; 58 | 59 | const subject = () => canvasPath({ params, query }); 60 | 61 | beforeEach(() => { 62 | params = { folderId: 7 }; 63 | query = { category: "uncategorized" }; 64 | }); 65 | 66 | it("adds the category as a query param", () => { 67 | assert.equal( 68 | subject(), 69 | "/api/v1/folders/7/files?per_page=undefined&include[]=preview_url&use_verifiers=0&category=uncategorized" 70 | ); 71 | }); 72 | }); 73 | }); 74 | }); 75 | 76 | describe("canvasResponseHandler()", () => { 77 | let request, response, canvasResponse; 78 | 79 | beforeEach(() => { 80 | request = { get: () => {} }; 81 | response = { 82 | status: sinon.spy(), 83 | send: sinon.spy() 84 | }; 85 | canvasResponse = { 86 | status: 200, 87 | body: [] 88 | }; 89 | }); 90 | 91 | it("sends status from canvasResponse", () => { 92 | canvasResponseHandler(request, response, canvasResponse); 93 | response.status.calledWith(canvasResponse.status); 94 | }); 95 | 96 | it("sends body from canvasResponse for non-200 responses", () => { 97 | canvasResponse.status = 400; 98 | canvasResponseHandler(request, response, canvasResponse); 99 | response.send.calledWith(canvasResponse.body); 100 | }); 101 | 102 | describe("transformed response body", () => { 103 | let file = null; 104 | 105 | beforeEach(() => { 106 | file = { 107 | created_at: "2021-08-12T18:30:53Z", 108 | id: 47, 109 | uuid: "123123123asdf", 110 | "content-type": "text/plain", 111 | display_name: "Foo", 112 | filename: "Foo.pdf", 113 | preview_url: "someCANVADOCSurl", 114 | url: "someurl", 115 | folder_id: 1, 116 | embedded_iframe_url: "https://canvas.com/foo/bar", 117 | thumbnail_url: "https://canvas.com/foo/bar/thumbnail", 118 | category: "foo", 119 | media_entry_id: "bar" 120 | }; 121 | }); 122 | 123 | describe("when no thumbnail_url is given", () => { 124 | beforeEach(() => { 125 | file.thumbnail_url = undefined; 126 | }); 127 | 128 | it("uses the url", () => { 129 | canvasResponse.body = [file]; 130 | canvasResponse.statusCode = 200; 131 | canvasResponseHandler(request, response, canvasResponse); 132 | assert.deepStrictEqual(response.send.firstCall.args[0].files[0], { 133 | createdAt: "2021-08-12T18:30:53Z", 134 | id: 47, 135 | uuid: "123123123asdf", 136 | type: "text/plain", 137 | name: "Foo", 138 | url: "someurl", 139 | embed: { type: "scribd" }, 140 | folderId: 1, 141 | iframeUrl: "https://canvas.com/foo/bar", 142 | thumbnailUrl: "someurl", 143 | category: "foo", 144 | mediaEntryId: "bar" 145 | }); 146 | }); 147 | }); 148 | 149 | it("creates files array property with items from response body", () => { 150 | canvasResponse.body = [{}, {}, {}]; 151 | canvasResponseHandler(request, response, canvasResponse); 152 | response.send.calledWithMatch(val => { 153 | return ( 154 | Array.isArray(val.files) && 155 | val.files.length === canvasResponse.body.length 156 | ); 157 | }); 158 | }); 159 | 160 | it("files have correctly tranformed properties", () => { 161 | file.media_entry_id = null; 162 | canvasResponse.body = [file]; 163 | canvasResponse.statusCode = 200; 164 | canvasResponseHandler(request, response, canvasResponse); 165 | assert.deepStrictEqual(response.send.firstCall.args[0].files[0], { 166 | createdAt: "2021-08-12T18:30:53Z", 167 | id: 47, 168 | uuid: "123123123asdf", 169 | type: "text/plain", 170 | name: "Foo", 171 | url: "someurl", 172 | embed: { type: "scribd" }, 173 | folderId: 1, 174 | iframeUrl: "https://canvas.com/foo/bar", 175 | thumbnailUrl: "https://canvas.com/foo/bar/thumbnail", 176 | category: "foo", 177 | mediaEntryId: null 178 | }); 179 | }); 180 | 181 | it("will use fallbacks for name", () => { 182 | file.display_name = undefined; 183 | canvasResponse.body = [file]; 184 | canvasResponseHandler(request, response, canvasResponse); 185 | response.send.calledWithMatch(val => { 186 | return sinon.match({ name: file.filename }, val[0]); 187 | }); 188 | }); 189 | 190 | it("has bookmark from canvasResponse", () => { 191 | canvasResponse.bookmark = "foo"; 192 | canvasResponseHandler(request, response, canvasResponse); 193 | response.send.calledWithMatch(val => { 194 | return /foo/.test(val.bookmark); 195 | }); 196 | }); 197 | 198 | it("has null bookmark if canvasResponse does not have one", () => { 199 | canvasResponse.bookmark = undefined; 200 | canvasResponseHandler(request, response, canvasResponse); 201 | response.send.calledWithMatch(val => { 202 | return val.bookmark === null; 203 | }); 204 | }); 205 | }); 206 | }); 207 | }); 208 | -------------------------------------------------------------------------------- /test/service/api/flickrSearch.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const flickrSearch = require("../../../app/api/flickrSearch"); 4 | const assert = require("assert"); 5 | const nock = require("nock"); 6 | 7 | describe("Flickr Search", () => { 8 | beforeEach(() => { 9 | process.env.FLICKR_API_KEY = "some-long-api-key"; 10 | }); 11 | 12 | afterEach(() => { 13 | process.env.FLICKR_API_KEY = undefined; 14 | }); 15 | 16 | let buildFlickrScope = (body, searchTerm) => { 17 | let queryParams = { 18 | method: "flickr.photos.search", 19 | format: "json", 20 | sort: "relevance", 21 | license: "1,2,3,4,5,6", 22 | per_page: "15", 23 | nojsoncallback: "1", 24 | api_key: "some-long-api-key", 25 | text: searchTerm, 26 | extras: "needs_interstitial" 27 | }; 28 | return nock("https://api.flickr.com") 29 | .get("/services/rest") 30 | .query(queryParams) 31 | .reply(200, { 32 | photos: { 33 | photo: body 34 | } 35 | }); 36 | }; 37 | 38 | let buildResponseForScope = (scope, done) => { 39 | return { 40 | status: () => {}, 41 | send: () => { 42 | assert.ok(scope.isDone()); 43 | done(); 44 | } 45 | }; 46 | }; 47 | 48 | let buildRequest = searchTerm => { 49 | return { 50 | query: { term: searchTerm } 51 | }; 52 | }; 53 | 54 | it("uses the api key and search term", done => { 55 | let req = buildRequest("chess"); 56 | let scope = buildFlickrScope([], "chess"); 57 | let resp = buildResponseForScope(scope, done); 58 | flickrSearch(req, resp); 59 | }); 60 | 61 | it("uri encodes search term", done => { 62 | let req = buildRequest("cute cats"); 63 | let scope = buildFlickrScope([], "cute cats"); 64 | let resp = buildResponseForScope(scope, done); 65 | flickrSearch(req, resp); 66 | }); 67 | 68 | it("transforms results into useful links", done => { 69 | let req = buildRequest("am"); 70 | let body = [ 71 | { farm: 1, server: 2, id: 3, secret: 4, title: "ham" }, 72 | { farm: 5, server: 6, id: 7, secret: 8, title: "spam" } 73 | ]; 74 | buildFlickrScope(body, "am"); 75 | 76 | let resp = { 77 | status: () => {}, 78 | send: body => { 79 | assert.equal(body[0].href, "https://farm1.static.flickr.com/2/3_4.jpg"); 80 | done(); 81 | } 82 | }; 83 | 84 | flickrSearch(req, resp); 85 | }); 86 | 87 | it("transform includes link to web page for photo", done => { 88 | let req = buildRequest("am"); 89 | let body = [ 90 | { farm: 1, server: 2, id: 3, secret: 4, title: "ham", owner: "bill" }, 91 | { farm: 5, server: 6, id: 7, secret: 8, title: "spam", owner: "bob" } 92 | ]; 93 | buildFlickrScope(body, "am"); 94 | 95 | let resp = { 96 | status: () => {}, 97 | send: body => { 98 | try { 99 | assert.equal(body[0].link, "https://www.flickr.com/photos/bill/3"); 100 | assert.equal(body[1].link, "https://www.flickr.com/photos/bob/7"); 101 | done(); 102 | } catch (err) { 103 | done(err); 104 | } 105 | } 106 | }; 107 | 108 | flickrSearch(req, resp); 109 | }); 110 | 111 | it("transformSearchResults removes photos with needs_interstitial=1", done => { 112 | let req = buildRequest("am"); 113 | let body = [ 114 | { 115 | farm: 1, 116 | server: 2, 117 | id: 3, 118 | secret: 4, 119 | title: "ham", 120 | owner: "bill", 121 | needs_interstitial: 1 122 | }, 123 | { 124 | farm: 5, 125 | server: 6, 126 | id: 7, 127 | secret: 8, 128 | title: "spam", 129 | owner: "bob", 130 | needs_interstitial: 0 131 | } 132 | ]; 133 | buildFlickrScope(body, "am"); 134 | 135 | let resp = { 136 | status: () => {}, 137 | send: body => { 138 | try { 139 | assert.equal(body.length, 1); 140 | assert.equal(body[0].link, "https://www.flickr.com/photos/bob/7"); 141 | done(); 142 | } catch (err) { 143 | done(err); 144 | } 145 | } 146 | }; 147 | 148 | flickrSearch(req, resp); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/service/api/images.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const { 6 | canvasPath, 7 | canvasResponseHandler 8 | } = require("../../../app/api/images"); 9 | 10 | describe("Images API", () => { 11 | describe("canvasPath()", () => { 12 | it("builds the correct path including context id", () => { 13 | const id = 47; 14 | const params = {}; 15 | const query = { contextId: id, contextType: "course", per_page: 50 }; 16 | assert( 17 | canvasPath({ params, query }) === 18 | `/api/v1/courses/${id}/files?per_page=50&content_types[]=image&use_verifiers=0` 19 | ); 20 | }); 21 | 22 | it("handles group contexts", () => { 23 | const contextId = 47; 24 | const params = {}; 25 | const query = { contextType: "group", contextId, per_page: 50 }; 26 | const path = canvasPath({ params, query }); 27 | assert( 28 | path === 29 | `/api/v1/groups/${contextId}/files?per_page=50&content_types[]=image&use_verifiers=0` 30 | ); 31 | }); 32 | 33 | it("handles user contexts", () => { 34 | const contextId = 47; 35 | const params = {}; 36 | const query = { contextType: "user", contextId, per_page: 50 }; 37 | const path = canvasPath({ params, query }); 38 | assert( 39 | path === 40 | `/api/v1/users/${contextId}/files?per_page=50&content_types[]=image&use_verifiers=0` 41 | ); 42 | }); 43 | }); 44 | 45 | describe("canvasResponseHandler()", () => { 46 | let request, response, canvasResponse; 47 | 48 | beforeEach(() => { 49 | request = { 50 | protocol: "http", 51 | get: () => "canvashost" 52 | }; 53 | response = { 54 | status: sinon.spy(), 55 | send: sinon.spy() 56 | }; 57 | canvasResponse = { 58 | statusCode: 200, 59 | body: [] 60 | }; 61 | }); 62 | 63 | it("sends status from canvasResponse", () => { 64 | canvasResponseHandler(request, response, canvasResponse); 65 | sinon.assert.calledWith(response.status, canvasResponse.statusCode); 66 | }); 67 | 68 | it("sends body from canvasResponse for non-200 responses", () => { 69 | canvasResponse.statusCode = 400; 70 | canvasResponseHandler(request, response, canvasResponse); 71 | sinon.assert.calledWith(response.send, canvasResponse.body); 72 | }); 73 | 74 | describe("transformed response body", () => { 75 | beforeEach(() => { 76 | canvasResponse.body = [ 77 | { 78 | id: 1, 79 | filename: "filename", 80 | thumbnail_url: "thumbnail.jpg", 81 | display_name: "look!", 82 | url: "URL", 83 | other: "unused_field" 84 | } 85 | ]; 86 | canvasResponseHandler(request, response, canvasResponse); 87 | }); 88 | 89 | it("simplifes API response", () => { 90 | sinon.assert.calledWithMatch(response.send, val => { 91 | return val.images[0].id == 1 && val.images[0].other === undefined; 92 | }); 93 | }); 94 | 95 | it("uses the big image for the href (which is for embedding)", () => { 96 | sinon.assert.calledWithMatch(response.send, val => { 97 | return ( 98 | val.images[0].thumbnail_url == "thumbnail.jpg" && 99 | val.images[0].preview_url == "URL" && 100 | val.images[0].href == "URL" 101 | ); 102 | }); 103 | }); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /test/service/api/index.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const inja = require("inja"); 4 | const _api = require("../../../app/api"); 5 | const _config = require("../../../app/config"); 6 | const _token = require("../../../app/token"); 7 | const assert = require("assert"); 8 | const express = require("express"); 9 | const request = require("supertest"); 10 | const moment = require("moment"); 11 | const nock = require("nock"); 12 | const addRequestId = require("express-request-id")(); 13 | 14 | describe("api proxying", function() { 15 | let container, tokenPayload, tokenString, token; 16 | 17 | function getProxied(path, tokenString) { 18 | const app = express(); 19 | 20 | app.use(addRequestId); // necessary for id passing 21 | container.make(_api).applyToApp(app); 22 | return request(app) 23 | .get(path) 24 | .set("Accept", "application/json") 25 | .set("Authorization", "Bearer " + tokenString); 26 | } 27 | 28 | beforeEach(async () => { 29 | container = inja().implement(_config, { 30 | init() { 31 | return { 32 | get() {}, 33 | 34 | require(key) { 35 | switch (key) { 36 | case "token.signingSecret": 37 | return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 38 | case "token.encryptionSecret": 39 | return "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; 40 | } 41 | } 42 | }; 43 | } 44 | }); 45 | 46 | tokenPayload = { 47 | iss: "Canvas", 48 | aud: ["Instructure"], 49 | domain: "test.canvas.local", 50 | exp: moment() 51 | .add(1, "hours") 52 | .unix(), 53 | nbf: moment() 54 | .subtract(30, "seconds") 55 | .unix(), 56 | iat: moment().unix(), 57 | jti: "some-long-string-that-is-unique", 58 | sub: 42 59 | }; 60 | 61 | token = container.make(_token); 62 | tokenString = await token.create(tokenPayload); 63 | }); 64 | 65 | afterEach(() => {}); 66 | 67 | it("proxies api requests from good tokens", function(done) { 68 | nock("http://test.canvas.local") 69 | .get( 70 | "/api/v1/folders/1/files?per_page=50&include[]=preview_url&use_verifiers=0" 71 | ) 72 | .reply(200, [ 73 | { 74 | display_name: "file.txt", 75 | created_at: "2012-07-06T14:58:50Z", 76 | updated_at: "2012-07-06T14:58:50Z" 77 | } 78 | ]); 79 | 80 | getProxied("/api/files/1", tokenString) 81 | .expect(200) 82 | .expect(/file.txt/) 83 | .end(done); 84 | }); 85 | 86 | it("passes on response codes", function(done) { 87 | nock("http://test.canvas.local") 88 | .get( 89 | "/api/v1/folders/1/files?per_page=50&include[]=preview_url&use_verifiers=0" 90 | ) 91 | .reply(500, { error: "an internal error occurred" }); 92 | 93 | getProxied("/api/files/1", tokenString) 94 | .expect(500) 95 | .expect(/error/) 96 | .end(done); 97 | }); 98 | 99 | it("uses query parameters to construct paths", function(done) { 100 | nock("http://test.canvas.local") 101 | .get("/api/v1/courses/999/pages?sort=title&per_page=50") 102 | .reply(200, [ 103 | { 104 | url: "my-page-title", 105 | title: "My Page Title", 106 | created_at: "2012-08-06T16:46:33-06:00", 107 | updated_at: "2012-08-08T14:25:20-06:00" 108 | } 109 | ]); 110 | 111 | getProxied("/api/wikiPages?contextType=course&contextId=999", tokenString) 112 | .expect(200) 113 | .expect(/My Page Title/) 114 | .end(done); 115 | }); 116 | 117 | it("catches bad tokens before proxying", async () => { 118 | tokenPayload.exp = moment() 119 | .subtract(10, "minutes") 120 | .unix(); 121 | const scope = nock("http://test.canvas.local") 122 | .get( 123 | "/api/v1/folders/1/files?per_page=50&include[]=preview_url&use_verifiers=0" 124 | ) 125 | .reply(200, []); 126 | 127 | const badToken = await token.create(tokenPayload); 128 | return new Promise((resolve, reject) => { 129 | getProxied("/api/files/1", badToken) 130 | .expect(401) 131 | .expect(/token expired/) 132 | .end(err => { 133 | try { 134 | assert.ok(!scope.isDone()); 135 | } catch (caughtErr) { 136 | err = caughtErr; 137 | } 138 | err ? reject(err) : resolve(); 139 | }); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/service/api/linksResponseHandler.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const linksResponseHandler = require("../../../app/api/linksResponseHandler"); 6 | 7 | describe("Link API response handlers", () => { 8 | const request = {}; 9 | const response = { status: () => {}, send: () => {} }; 10 | const staticTransformResult = "Static transform result"; 11 | const staticTransform = () => { 12 | return staticTransformResult; 13 | }; 14 | 15 | describe("non-200 canvas responses", () => { 16 | const failedCanvasResponse = { 17 | statusCode: 404, 18 | body: { error: "Resource does not exist" } 19 | }; 20 | 21 | let handler; 22 | beforeEach(() => { 23 | handler = linksResponseHandler(staticTransform); 24 | }); 25 | 26 | it("just forwards status", () => { 27 | sinon.spy(response, "status"); 28 | handler(request, response, failedCanvasResponse); 29 | assert.ok(response.status.calledWith(failedCanvasResponse.statusCode)); 30 | response.status.restore(); 31 | }); 32 | 33 | it("just forwards body", () => { 34 | sinon.spy(response, "send"); 35 | handler(request, response, failedCanvasResponse); 36 | assert.ok(response.send.calledWith(failedCanvasResponse.body)); 37 | response.send.restore(); 38 | }); 39 | }); 40 | 41 | describe("successful canvas responses", () => { 42 | const canvasResponse = { 43 | statusCode: 200, 44 | body: [ 45 | { html_url: "/courses/1/announcements/2", title: "Announcement 2" } 46 | ] 47 | }; 48 | 49 | let handler; 50 | beforeEach(() => { 51 | handler = linksResponseHandler(staticTransform); 52 | }); 53 | 54 | it("has a status of 200", () => { 55 | sinon.spy(response, "status"); 56 | handler(request, response, canvasResponse); 57 | assert.ok(response.status.calledWith(200)); 58 | response.status.restore(); 59 | }); 60 | 61 | it("puts the result of the transform in the links key", () => { 62 | sinon.spy(response, "send"); 63 | handler(request, response, canvasResponse); 64 | const sentBody = response.send.firstCall.args[0]; 65 | assert.deepEqual(sentBody.links, staticTransformResult); 66 | response.send.restore(); 67 | }); 68 | 69 | it("leaves the bookmark null if the canvas response didn't have one", () => { 70 | sinon.spy(response, "send"); 71 | handler(request, response, canvasResponse); 72 | const sentBody = response.send.firstCall.args[0]; 73 | assert.equal(sentBody.bookmark, undefined); 74 | response.send.restore(); 75 | }); 76 | 77 | describe("bookmark construction", () => { 78 | const canvasBookmark = "/canvasBookmark"; 79 | const bookmarkedCanvasResponse = Object.assign({}, canvasResponse, { 80 | bookmark: canvasBookmark 81 | }); 82 | const request = { 83 | protocol: "http", 84 | get: header => header === "Host" && "rce.example", 85 | baseUrl: "/api", 86 | path: "/wikiPages", 87 | query: { 88 | contextType: "course", 89 | contextId: "123", 90 | bookmark: "/oldBookmark" 91 | } 92 | }; 93 | 94 | let bookmark; 95 | beforeEach(() => { 96 | sinon.spy(response, "send"); 97 | handler(request, response, bookmarkedCanvasResponse); 98 | bookmark = response.send.firstCall.args[0].bookmark; 99 | response.send.restore(); 100 | }); 101 | 102 | it("keeps the request's protocol and host", () => { 103 | assert.ok(bookmark.match("^http://rce.example")); 104 | }); 105 | 106 | it("keeps the request's endpoint", () => { 107 | assert.ok(bookmark.match("/api/wikiPages")); 108 | }); 109 | 110 | it("keeps the request's query parameters", () => { 111 | assert.ok(bookmark.match("contextType=course")); 112 | assert.ok(bookmark.match("contextId=123")); 113 | }); 114 | 115 | it("doesn't keep the request's old bookmark", () => { 116 | assert.ok(!bookmark.match(encodeURIComponent(request.query.bookmark))); 117 | }); 118 | 119 | it("adds the canvas bookmark", () => { 120 | assert.ok( 121 | bookmark.match(`bookmark=${encodeURIComponent(canvasBookmark)}`) 122 | ); 123 | }); 124 | }); 125 | }); 126 | }); 127 | -------------------------------------------------------------------------------- /test/service/api/mediaTracks.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const { 6 | canvasPath, 7 | transformBody, 8 | canvasResponseHandler 9 | } = require("../../../app/api/mediaTracks"); 10 | 11 | describe("MediaTracks API", () => { 12 | describe("canvasPath()", () => { 13 | describe("GET", () => { 14 | it("builds the correct path for media_objects", () => { 15 | const params = { mediaObjectId: "m-theidgoeshere" }; 16 | const query = {}; 17 | const path = canvasPath({ params, query, method: "GET" }); 18 | assert.equal( 19 | path, 20 | "/api/v1/media_objects/m-theidgoeshere/media_tracks" 21 | ); 22 | }); 23 | 24 | it("builds the correct path for media_attachments", () => { 25 | const params = { mediaAttachmentId: "attach-id" }; 26 | const query = {}; 27 | const path = canvasPath({ params, query, method: "GET" }); 28 | assert.equal(path, "/api/v1/media_attachments/attach-id/media_tracks"); 29 | }); 30 | 31 | it("builds the correct path with 'include' query param", () => { 32 | const params = { mediaObjectId: "m-theidgoeshere" }; 33 | const query = { include: "content,user_id" }; 34 | const path = canvasPath({ params, query, method: "GET" }); 35 | assert.equal( 36 | path, 37 | "/api/v1/media_objects/m-theidgoeshere/media_tracks?include[]=content&include[]=user_id" 38 | ); 39 | }); 40 | }); 41 | 42 | describe("PUT", () => { 43 | it("builds the correct path for media_objects", () => { 44 | const params = { mediaObjectId: "m-theidgoeshere" }; 45 | const query = {}; 46 | const path = canvasPath({ params, query, method: "PUT" }); 47 | assert.equal( 48 | path, 49 | "/api/v1/media_objects/m-theidgoeshere/media_tracks" 50 | ); 51 | }); 52 | 53 | it("builds the correct path for media_attachments", () => { 54 | const params = { mediaAttachmentId: "attach-id" }; 55 | const query = {}; 56 | const path = canvasPath({ params, query, method: "PUT" }); 57 | assert.equal(path, "/api/v1/media_attachments/attach-id/media_tracks"); 58 | }); 59 | 60 | it("builds the correct path with 'include' query param", () => { 61 | const params = { mediaObjectId: "m-theidgoeshere" }; 62 | const query = { include: "content,user_id" }; 63 | const path = canvasPath({ params, query, method: "PUT" }); 64 | assert.equal( 65 | path, 66 | "/api/v1/media_objects/m-theidgoeshere/media_tracks?include[]=content&include[]=user_id" 67 | ); 68 | }); 69 | 70 | it("builds the correct path with the array form of the 'include' query param", () => { 71 | const params = { mediaObjectId: "m-theidgoeshere" }; 72 | const query = { include: ["content", "user_id"] }; 73 | const path = canvasPath({ params, query, method: "PUT" }); 74 | assert.equal( 75 | path, 76 | "/api/v1/media_objects/m-theidgoeshere/media_tracks?include[]=content&include[]=user_id" 77 | ); 78 | }); 79 | }); 80 | 81 | describe("tansformBody()", () => { 82 | it("returns JSON string version of input", () => { 83 | const body = [ 84 | { 85 | locale: "es", 86 | content: "1]\\n00:00:00,000 --> 00:00:01,251\nI'm spanish" 87 | }, 88 | { locale: "en" } 89 | ]; 90 | const result = transformBody(body); 91 | assert.equal(JSON.stringify(body), result); 92 | }); 93 | 94 | it("copes with an empty array if tracks", () => { 95 | const body = []; 96 | const result = transformBody(body); 97 | assert.equal(JSON.stringify(body), result); 98 | }); 99 | 100 | it("rejects a track that's missing 'locale'", () => { 101 | try { 102 | transformBody([ 103 | { content: "1]\\n00:00:00,000 --> 00:00:01,251\nI'm english" } 104 | ]); 105 | } catch (e) { 106 | assert.equal(e.status, 400); 107 | assert.equal(e.message, JSON.stringify({ error: "locale required" })); 108 | } 109 | }); 110 | 111 | it("rejects a body that's not an array", () => { 112 | try { 113 | transformBody({ 114 | locale: "en", 115 | content: "1]\\n00:00:00,000 --> 00:00:01,251\nI'm english" 116 | }); 117 | } catch (e) { 118 | assert.equal(e.status, 400); 119 | assert.equal( 120 | e.message, 121 | JSON.stringify({ error: "expected an array of tracks" }) 122 | ); 123 | } 124 | }); 125 | }); 126 | 127 | describe("canvasResponseHandler()", () => { 128 | let request, response, canvasResponse; 129 | 130 | beforeEach(() => { 131 | request = { 132 | protocol: "http", 133 | put: () => "canvashost" 134 | }; 135 | response = { 136 | status: sinon.spy(), 137 | send: sinon.spy() 138 | }; 139 | canvasResponse = { 140 | statusCode: 200, 141 | body: JSON.stringify('[{id: 1, locale: "en"}]') 142 | }; 143 | }); 144 | 145 | it("sends status from canvasResponse", () => { 146 | canvasResponseHandler(request, response, canvasResponse); 147 | sinon.assert.calledWith(response.status, canvasResponse.statusCode); 148 | }); 149 | 150 | it("sends body from canvasResponse", () => { 151 | canvasResponseHandler(request, response, canvasResponse); 152 | sinon.assert.calledWith(response.send, canvasResponse.body); 153 | }); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/service/api/modules.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const modules = require("../../../app/api/modules"); 6 | 7 | describe("Modules API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let path; 11 | beforeEach(() => { 12 | const query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | path = modules.canvasPath({ query }); 14 | }); 15 | 16 | it("builds course paths", () => { 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | assert.ok(path.match("courses/123")); 22 | }); 23 | 24 | it("asks for modules", () => { 25 | assert.ok(path.match("modules")); 26 | }); 27 | 28 | it("passes per_page through", () => { 29 | assert.ok(path.match("per_page=50")); 30 | }); 31 | }); 32 | 33 | it("throws on group context", () => { 34 | const query = { contextType: "group", contextId: "456", per_page: 50 }; 35 | assert.throws(() => modules.canvasPath({ query })); 36 | }); 37 | 38 | it("throws on user context", () => { 39 | const query = { contextType: "user", contextId: "self", per_page: 50 }; 40 | assert.throws(() => modules.canvasPath({ query })); 41 | }); 42 | }); 43 | 44 | describe("canvasResponseHandler", () => { 45 | const request = { query: { contextId: 123 } }; 46 | const response = { status: () => {}, send: () => {} }; 47 | const canvasResponse = { 48 | statusCode: 200, 49 | body: [{ id: "456", name: "Module 2", published: true }] 50 | }; 51 | 52 | let result; 53 | beforeEach(() => { 54 | sinon.spy(response, "send"); 55 | modules.canvasResponseHandler(request, response, canvasResponse); 56 | result = response.send.firstCall.args[0]; 57 | response.send.restore(); 58 | }); 59 | 60 | it("constructs href using request's contextId", () => { 61 | assert.ok(result.links[0].href.match(request.query.contextId)); 62 | }); 63 | 64 | it("constructs href using canvas' id", () => { 65 | assert.ok(result.links[0].href.match(canvasResponse.body[0].id)); 66 | }); 67 | 68 | it("pulls title from canvas' name", () => { 69 | assert.equal(result.links[0].title, canvasResponse.body[0].name); 70 | }); 71 | 72 | it("pulls the published state from canvas' response", () => { 73 | assert.equal(result.links[0].published, canvasResponse.body[0].published); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/service/api/packageBookmark.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal } = require("assert"); 4 | const packageBookmark = require("../../../app/api/packageBookmark"); 5 | 6 | describe("packageBookmark", () => { 7 | let request, bookmark; 8 | 9 | beforeEach(() => { 10 | request = { 11 | baseUrl: "/a/b", 12 | path: "/c", 13 | query: { id: 47, bookmark: "oldbookmark" }, 14 | protocol: "http", 15 | get: header => header === "Host" && "somehost:3000" 16 | }; 17 | bookmark = "canvasurl"; 18 | }); 19 | 20 | it("returns null with no bookmark", () => { 21 | const url = packageBookmark(request); 22 | equal(url, null); 23 | }); 24 | 25 | it("forms corect url", () => { 26 | const url = packageBookmark(request, bookmark); 27 | equal(url, "http://somehost:3000/a/b/c?id=47&bookmark=canvasurl"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/service/api/quizzes.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const quizzes = require("../../../app/api/quizzes"); 6 | 7 | describe("Quizzes API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let query; 11 | beforeEach(() => { 12 | query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | }); 14 | 15 | it("builds course paths", () => { 16 | const path = quizzes.canvasPath({ query }); 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | const path = quizzes.canvasPath({ query }); 22 | assert.ok(path.match("courses/123")); 23 | }); 24 | 25 | it("asks for quizzes", () => { 26 | const path = quizzes.canvasPath({ query }); 27 | assert.ok(path.match("quizzes")); 28 | }); 29 | 30 | it("passes per_page through", () => { 31 | const path = quizzes.canvasPath({ query }); 32 | assert.ok(path.match("per_page=50")); 33 | }); 34 | 35 | it("includes search term", () => { 36 | query.search_term = "hello"; 37 | const path = quizzes.canvasPath({ query }); 38 | assert.ok(path.match("&search_term=hello")); 39 | }); 40 | }); 41 | 42 | it("throws on group context", () => { 43 | const query = { contextType: "group", contextId: "456", per_page: 50 }; 44 | assert.throws(() => quizzes.canvasPath({ query })); 45 | }); 46 | 47 | it("throws on user context", () => { 48 | const query = { contextType: "user", contextId: "self", per_page: 50 }; 49 | assert.throws(() => quizzes.canvasPath({ query })); 50 | }); 51 | }); 52 | 53 | describe("canvasResponseHandler", () => { 54 | const request = {}; 55 | const response = { status: () => {}, send: () => {} }; 56 | 57 | function setup(statusCode = 200, overrides = {}) { 58 | const canvasResponse = { 59 | statusCode: statusCode, 60 | body: [ 61 | { 62 | html_url: "/courses/1/quizzes/2", 63 | title: "Quiz 2", 64 | published: true, 65 | all_dates: [{ due_at: "2019-04-22T13:00:00Z" }], 66 | due_at: "2019-04-22T13:00:00Z", 67 | quiz_type: "assignment", 68 | ...overrides 69 | } 70 | ] 71 | }; 72 | 73 | sinon.spy(response, "send"); 74 | quizzes.canvasResponseHandler(request, response, canvasResponse); 75 | const result = response.send.firstCall.args[0]; 76 | response.send.restore(); 77 | return [result, canvasResponse]; 78 | } 79 | 80 | it("pulls href from canvas' html_url", () => { 81 | const [result, canvasResponse] = setup(); 82 | assert.equal(result.links[0].href, canvasResponse.body[0].html_url); 83 | }); 84 | 85 | it("pulls title from canvas' title", () => { 86 | const [result, canvasResponse] = setup(); 87 | assert.equal(result.links[0].title, canvasResponse.body[0].title); 88 | }); 89 | 90 | it("pulls the published state from canvas' response", () => { 91 | const [result, canvasResponse] = setup(); 92 | assert.equal(result.links[0].published, canvasResponse.body[0].published); 93 | }); 94 | 95 | it("pulls the due date from canvas' response", () => { 96 | const [result, canvasResponse] = setup(); 97 | assert.equal(result.links[0].date, canvasResponse.body[0].due_at); 98 | assert.equal(result.links[0].date_type, "due"); 99 | }); 100 | 101 | it("handles multiple due dates in canvas's response", () => { 102 | const [result] = setup(200, { 103 | all_dates: [ 104 | { due_at: "2019-04-22T13:00:00Z" }, 105 | { due_at: "2019-04-23T13:00:00Z" } 106 | ], 107 | due_at: null 108 | }); 109 | assert.equal(result.links[0].date, "multiple"); 110 | assert.equal(result.links[0].date_type, "due"); 111 | }); 112 | 113 | it("pulls the quiz_type from canvas' response", () => { 114 | const [result, canvasResponse] = setup(); 115 | assert.equal(result.links[0].quiz_type, canvasResponse.body[0].quiz_type); 116 | }); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /test/service/api/rceConfig.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | 5 | const { canvasPath } = require("../../../app/api/rceConfig"); 6 | 7 | describe("RCE config API", () => { 8 | describe("canvasPath", () => { 9 | it("builds the correct path including with course_id parameter", () => { 10 | const path = canvasPath({ 11 | query: { contextType: "course", contextId: "1" } 12 | }); 13 | assert.ok(path.match("/api/v1/services/rce_config\\?course_id=1")); 14 | }); 15 | 16 | it("builds the correct path including with user_id parameter", () => { 17 | const path = canvasPath({ 18 | query: { contextType: "user", contextId: "1" } 19 | }); 20 | assert.ok(path.match("/api/v1/services/rce_config\\?user_id=1")); 21 | }); 22 | 23 | it("builds the correct path including with group_id parameter", () => { 24 | const path = canvasPath({ 25 | query: { contextType: "group", contextId: "1" } 26 | }); 27 | assert.ok(path.match("/api/v1/services/rce_config\\?group_id=1")); 28 | }); 29 | 30 | it("builds the correct path including with account_id parameter", () => { 31 | const path = canvasPath({ 32 | query: { contextType: "account", contextId: "101230234" } 33 | }); 34 | assert.ok(path.match("/api/v1/services/rce_config\\?account_id=101230234")); 35 | }); 36 | 37 | it("throws error in case of missing context type", () => { 38 | assert.throws(() => canvasPath({ query: { contextId: "1" } })); 39 | }); 40 | 41 | it("throws error in case of invalid context type", () => { 42 | assert.throws(() => 43 | canvasPath({ query: { contextType: "asd", contextId: "1" } }) 44 | ); 45 | }); 46 | 47 | it("throws error in case of missing context id", () => { 48 | assert.throws(() => canvasPath({ query: { contextType: "asd" } })); 49 | }); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /test/service/api/session.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const sessionHandler = require("../../../app/api/session"); 4 | const sinon = require("sinon"); 5 | 6 | describe("Session handler", () => { 7 | it("responsed with expected data from token", () => { 8 | const req = { 9 | auth: { 10 | payload: { 11 | context_type: "Course", 12 | context_id: 47, 13 | workflow_state: { 14 | can_upload_files: true, 15 | usage_rights_required: false, 16 | use_high_contrast: true, 17 | can_create_pages: false 18 | }, 19 | domain: "canvas.docker" 20 | } 21 | } 22 | }; 23 | const res = { json: sinon.spy() }; 24 | sessionHandler(req, res); 25 | sinon.assert.calledWithMatch(res.json, { 26 | contextType: "Course", 27 | contextId: 47, 28 | canUploadFiles: true, 29 | usageRightsRequired: false, 30 | useHighContrast: true, 31 | canCreatePages: false, 32 | canvasUrl: "http://canvas.docker" 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/service/api/upload.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const { 6 | canvasPath, 7 | canvasResponseHandler, 8 | transformBody 9 | } = require("../../../app/api/upload"); 10 | 11 | describe("Upload API", () => { 12 | describe("canvasPath()", () => { 13 | it("builds the correct path including context id", () => { 14 | const path = canvasPath({ 15 | body: { 16 | contextType: "course", 17 | contextId: 47 18 | } 19 | }); 20 | assert(path === `/api/v1/courses/47/files`); 21 | }); 22 | 23 | it("handles group contexts", () => { 24 | const path = canvasPath({ 25 | body: { 26 | contextType: "group", 27 | contextId: 47 28 | } 29 | }); 30 | assert(path === `/api/v1/groups/47/files`); 31 | }); 32 | 33 | it("handles user contexts", () => { 34 | const path = canvasPath({ 35 | body: { 36 | contextType: "user", 37 | contextId: 47 38 | } 39 | }); 40 | assert(path === `/api/v1/users/47/files`); 41 | }); 42 | }); 43 | 44 | describe("canvasResponseHandler()", () => { 45 | let request, response, canvasResponse; 46 | 47 | beforeEach(() => { 48 | request = { 49 | protocol: "http", 50 | get: () => "canvashost" 51 | }; 52 | response = { 53 | status: sinon.spy(), 54 | send: sinon.spy() 55 | }; 56 | canvasResponse = { 57 | status: 200, 58 | body: [] 59 | }; 60 | }); 61 | 62 | it("sends status from canvasResponse", () => { 63 | canvasResponseHandler(request, response, canvasResponse); 64 | response.status.calledWith(canvasResponse.status); 65 | }); 66 | 67 | it("sends body from canvasResponse", () => { 68 | canvasResponseHandler(request, response, canvasResponse); 69 | response.send.calledWith(canvasResponse.body); 70 | }); 71 | }); 72 | 73 | describe("transformBody", () => { 74 | function getBody(overrides) { 75 | return { 76 | file: { 77 | name: "filename", 78 | size: 42, 79 | type: "jpeg", 80 | parentFolderId: 1 81 | }, 82 | ...overrides 83 | }; 84 | } 85 | 86 | it("reshapes the body to the format canvas wants", () => { 87 | const fixed = transformBody(getBody({ onDuplicate: "overwrite" })); 88 | assert.equal(fixed.name, "filename"); 89 | assert.equal(fixed.size, 42); 90 | assert.equal(fixed.contentType, "jpeg"); 91 | assert.equal(fixed.parent_folder_id, 1); 92 | assert.equal(fixed.on_duplicate, "overwrite"); 93 | }); 94 | 95 | it("renames files on duplicate instead of overwriting them", () => { 96 | const fixed = transformBody(getBody()); 97 | assert.equal(fixed.on_duplicate, "rename"); 98 | }); 99 | 100 | it("has success include preview url", () => { 101 | const fixed = transformBody(getBody()); 102 | assert.deepEqual(fixed.success_include, ["preview_url"]); 103 | }); 104 | 105 | it("uses contentType if type is missing", () => { 106 | const body = getBody(); 107 | delete body.file.type; 108 | body.file.contentType = "text/plain"; 109 | const fixed = transformBody(body); 110 | assert.equal(fixed.contentType, "text/plain"); 111 | }); 112 | 113 | describe('when "onDuplicate" is undefined', () => { 114 | const overrides = { onDuplicate: undefined }; 115 | 116 | const subject = () => transformBody(getBody(overrides)); 117 | 118 | it('defaults duplication strategy to "rename"', () => { 119 | assert.equal(subject().on_duplicate, "rename"); 120 | }); 121 | }); 122 | 123 | describe('when "category" is specified', () => { 124 | const overrides = { category: "icon_maker" }; 125 | 126 | const subject = () => transformBody(getBody(overrides)); 127 | 128 | it("sets the category in the body", () => { 129 | assert.equal(subject().category, "icon_maker"); 130 | }); 131 | }); 132 | 133 | describe('when "category" is not specified', () => { 134 | const subject = () => transformBody(getBody()); 135 | 136 | it("sets the category in the body", () => { 137 | assert.equal(subject().category, undefined); 138 | }); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /test/service/api/usageRights.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, throws } = require("assert"); 4 | const { canvasPath, transformBody } = require("../../../app/api/usageRights"); 5 | 6 | describe("Usage Rights API", () => { 7 | describe("canvasPath()", () => { 8 | describe("course context", () => { 9 | it("builds the correct path includeing context id", () => { 10 | const req = { 11 | auth: { payload: { context_type: "Course", context_id: 47 } } 12 | }; 13 | const path = canvasPath(req); 14 | equal(path, `/api/v1/courses/47/usage_rights`); 15 | }); 16 | }); 17 | 18 | describe("group context", () => { 19 | it("builds the correct path includeing context id", () => { 20 | const req = { 21 | auth: { payload: { context_type: "Group", context_id: 47 } } 22 | }; 23 | const path = canvasPath(req); 24 | equal(path, `/api/v1/groups/47/usage_rights`); 25 | }); 26 | }); 27 | 28 | describe("user context", () => { 29 | it("builds the correct path including context id", () => { 30 | const req = { 31 | auth: { payload: { context_type: "User", context_id: 47 } } 32 | }; 33 | const path = canvasPath(req); 34 | equal(path, `/api/v1/users/47/usage_rights`); 35 | }); 36 | }); 37 | 38 | it("dies on other contexts", () => { 39 | const query = { contextType: "NotAContext", contextId: 1, per_page: 50 }; 40 | throws(() => canvasPath({ params: {}, query: query })); 41 | }); 42 | }); 43 | 44 | describe("transformBody()", () => { 45 | it("includes file id in array", () => { 46 | const body = transformBody({ fileId: 47 }); 47 | equal(body.file_ids[0], 47); 48 | }); 49 | 50 | it("sets publish to true", () => { 51 | const body = transformBody({ fileId: 47 }); 52 | equal(body.publish, true); 53 | }); 54 | 55 | it("sets usage rights data", () => { 56 | const body = transformBody({ 57 | usageRight: "foo", 58 | copyrightHolder: "bar", 59 | ccLicense: "baz" 60 | }); 61 | equal(body.usage_rights.use_justification, "foo"); 62 | equal(body.usage_rights.legal_copyright, "bar"); 63 | equal(body.usage_rights.license, "baz"); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /test/service/api/wikiPages.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sinon = require("sinon"); 5 | const wikiPages = require("../../../app/api/wikiPages"); 6 | 7 | describe("Wiki Pages API", () => { 8 | describe("canvasPath", () => { 9 | describe("course context", () => { 10 | let query; 11 | beforeEach(() => { 12 | query = { contextType: "course", contextId: 123, per_page: 50 }; 13 | }); 14 | 15 | it("builds course paths", () => { 16 | const path = wikiPages.canvasPath({ query }); 17 | assert.ok(path.match("api/v1/courses")); 18 | }); 19 | 20 | it("uses context id in path", () => { 21 | const path = wikiPages.canvasPath({ query }); 22 | assert.ok(path.match("courses/123")); 23 | }); 24 | 25 | it("asks for wikiPages", () => { 26 | const path = wikiPages.canvasPath({ query }); 27 | assert.ok(path.match("pages")); 28 | }); 29 | 30 | it("passes per_page through", () => { 31 | const path = wikiPages.canvasPath({ query }); 32 | assert.ok(path.match("per_page=50")); 33 | }); 34 | 35 | it("sorts by title", () => { 36 | const path = wikiPages.canvasPath({ query }); 37 | assert.ok(path.match("sort=title")); 38 | }); 39 | 40 | it("includes search term", () => { 41 | query.search_term = "hello"; 42 | const path = wikiPages.canvasPath({ query }); 43 | assert.ok(path.match("&search_term=hello")); 44 | }); 45 | }); 46 | 47 | describe("group context", () => { 48 | let path; 49 | beforeEach(() => { 50 | const query = { contextType: "group", contextId: 456, per_page: 50 }; 51 | path = wikiPages.canvasPath({ query }); 52 | }); 53 | 54 | it("builds group paths", () => { 55 | assert.ok(path.match("api/v1/groups")); 56 | }); 57 | 58 | it("uses context id in path", () => { 59 | assert.ok(path.match("groups/456")); 60 | }); 61 | 62 | it("passes per_page through", () => { 63 | assert.ok(path.match("per_page=50")); 64 | }); 65 | 66 | it("sorts by title", () => { 67 | assert.ok(path.match("sort=title")); 68 | }); 69 | }); 70 | 71 | it("throws on user context", () => { 72 | const query = { contextType: "user", contextId: "self", per_page: 50 }; 73 | assert.throws(() => wikiPages.canvasPath({ query })); 74 | }); 75 | }); 76 | 77 | describe("canvasResponseHandler", () => { 78 | const request = {}; 79 | const response = { status: () => {}, send: () => {} }; 80 | const canvasResponse = { 81 | statusCode: 200, 82 | body: [ 83 | { 84 | html_url: "/courses/1/pages/the-page", 85 | title: "The Wiki Page", 86 | published: true, 87 | todo_date: "2019-04-22T13:00:00Z" 88 | } 89 | ] 90 | }; 91 | 92 | let result; 93 | beforeEach(() => { 94 | sinon.spy(response, "send"); 95 | wikiPages.canvasResponseHandler(request, response, canvasResponse); 96 | result = response.send.firstCall.args[0]; 97 | response.send.restore(); 98 | }); 99 | 100 | it("pulls href from canvas' html_url", () => { 101 | assert.equal(result.links[0].href, canvasResponse.body[0].html_url); 102 | }); 103 | 104 | it("pulls title from canvas' title", () => { 105 | assert.equal(result.links[0].title, canvasResponse.body[0].title); 106 | }); 107 | 108 | it("pulls the published state from canvas' response", () => { 109 | assert.equal(result.links[0].published, canvasResponse.body[0].published); 110 | }); 111 | 112 | it("pulls the due date from canvas' response", () => { 113 | assert.equal(result.links[0].date, canvasResponse.body[0].todo_date); 114 | assert.equal(result.links[0].date_type, "todo"); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/service/api/wrapCanvas.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const nock = require("nock"); 5 | const wrapCanvas = require("../../../app/api/wrapCanvas"); 6 | const sign = require("../../../app/utils/sign"); 7 | const { RuntimeException } = require("node-exceptions"); 8 | 9 | describe("wrapping a Canvas request/response", function() { 10 | const domain = "test.canvas.local"; 11 | const schemaAndHost = `http://${domain}`; 12 | const defaultPath = "/canvasPath"; 13 | 14 | function mockRequest(payload, wrappedToken) { 15 | return { 16 | query: {}, 17 | id: "request-id", 18 | auth: { payload, wrappedToken }, 19 | get: () => null 20 | }; 21 | } 22 | 23 | function buildWrapper(overrides) { 24 | return Object.assign( 25 | {}, 26 | { 27 | canvasPath() { 28 | return defaultPath; 29 | } 30 | }, 31 | overrides 32 | ); 33 | } 34 | 35 | let tokenPayload, response, request, wrappedToken; 36 | 37 | beforeEach(() => { 38 | tokenPayload = { domain }; 39 | wrappedToken = "goodwrapped"; 40 | request = mockRequest(tokenPayload, wrappedToken); 41 | response = { 42 | status: () => {}, 43 | send: () => {} 44 | }; 45 | }); 46 | 47 | it("uses the wrapper's path and token's domain for fetch", done => { 48 | const scope = nock(schemaAndHost) 49 | .get(defaultPath) 50 | .reply(200, "success"); 51 | wrapCanvas( 52 | buildWrapper({ 53 | canvasResponseHandler() { 54 | assert.ok(scope.isDone()); 55 | done(); 56 | } 57 | }) 58 | )(request, response); 59 | }); 60 | 61 | it("overrides wrapper path and token domain with the request's bookmark if given", done => { 62 | request.query.bookmark = sign.sign("http://otherHost/bookmark"); 63 | const scope = nock("http://otherHost") 64 | .get("/bookmark") 65 | .reply(200, "success"); 66 | wrapCanvas( 67 | buildWrapper({ 68 | canvasResponseHandler() { 69 | assert.ok(scope.isDone()); 70 | done(); 71 | } 72 | }) 73 | )(request, response); 74 | }); 75 | 76 | it("passes request, response, and canvas response to wrapper's response handler", done => { 77 | nock(schemaAndHost) 78 | .get(defaultPath) 79 | .reply(200, "success"); 80 | wrapCanvas( 81 | buildWrapper({ 82 | canvasResponseHandler(req, resp1, resp2) { 83 | assert.strictEqual(req, request); 84 | assert.strictEqual(resp1, response); 85 | assert.strictEqual(resp2.statusCode, 200); 86 | assert.strictEqual(resp2.body, "success"); 87 | done(); 88 | } 89 | }) 90 | )(request, response); 91 | }); 92 | 93 | it("posts with a body transformation when method specified", done => { 94 | const scope = nock(schemaAndHost) 95 | .post(defaultPath) 96 | .reply(200, "{}"); 97 | let wrapper = buildWrapper({ 98 | transformBody() { 99 | return JSON.stringify({ post: "body" }); 100 | }, 101 | 102 | canvasResponseHandler() { 103 | assert.ok(scope.isDone()); 104 | done(); 105 | } 106 | }); 107 | wrapCanvas(wrapper, { method: "POST" })(request, response); 108 | }); 109 | 110 | it("puts with a body transformation when method specified", done => { 111 | const scope = nock(schemaAndHost) 112 | .put(defaultPath) 113 | .reply(200, "{}"); 114 | let wrapper = buildWrapper({ 115 | transformBody() { 116 | return JSON.stringify({ put: "body" }); 117 | }, 118 | canvasResponseHandler() { 119 | assert.ok(scope.isDone()); 120 | done(); 121 | } 122 | }); 123 | wrapCanvas(wrapper, { method: "PUT" })(request, response); 124 | }); 125 | 126 | it("throws RuntimeException for an unrecognized method type", done => { 127 | nock(schemaAndHost) 128 | .get(defaultPath) 129 | .reply(200, "success"); 130 | let wrapper = buildWrapper({ 131 | canvasResponseHandler() {} 132 | }); 133 | wrapCanvas(wrapper, { method: "FOO" })(request, response, err => { 134 | try { 135 | assert.ok(err instanceof RuntimeException); 136 | done(); 137 | } catch (err) { 138 | done(err); 139 | } 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/service/api/youTubeApi.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const youTubeTitle = require("../../../app/api/youTubeApi"); 4 | const assert = require("assert"); 5 | const nock = require("nock"); 6 | 7 | describe("YouTube API", () => { 8 | beforeEach(() => { 9 | process.env.YOUTUBE_API_KEY = "some-long-api-key"; 10 | }); 11 | 12 | afterEach(() => { 13 | process.env.YOUTUBE_API_KEY = undefined; 14 | }); 15 | 16 | let buildYouTubeScope = (vidId, body, result = 200) => { 17 | let queryParams = { 18 | part: "snippet", 19 | maxResults: "2", 20 | q: `"${vidId}"`, 21 | key: "some-long-api-key" 22 | }; 23 | return nock("https://content.googleapis.com") 24 | .get("/youtube/v3/search") 25 | .query(queryParams) 26 | .reply(result, body); 27 | }; 28 | 29 | it("uses the api key and search term", done => { 30 | let vidId = "abcdefghijk"; 31 | let req = { query: { vid_id: vidId } }; 32 | let scope = buildYouTubeScope(vidId, { 33 | items: [ 34 | { 35 | id: { videoId: `${vidId}` }, 36 | snippet: { title: `my video title ${vidId}` } 37 | } 38 | ] 39 | }); 40 | let resp = { 41 | status: status => { 42 | assert.equal(status, 200); 43 | }, 44 | json: () => { 45 | assert.ok(scope.isDone()); 46 | done(); 47 | } 48 | }; 49 | youTubeTitle(req, resp); 50 | }); 51 | 52 | it("fails on vidId not found", done => { 53 | let vidId = "bogusidhere"; 54 | let req = { query: { vid_id: vidId } }; 55 | buildYouTubeScope(vidId, { 56 | items: [ 57 | { 58 | id: { videoId: "otherbogusis" }, 59 | snippet: { title: "my video title otherbogusid" } 60 | } 61 | ] 62 | }); 63 | let resp = { 64 | status: status => { 65 | assert.equal(status, 500); 66 | }, 67 | send: data => { 68 | assert.equal(data, `Video "${vidId}" not found.`); 69 | done(); 70 | } 71 | }; 72 | youTubeTitle(req, resp); 73 | }); 74 | 75 | it("fails on youtube call fail", done => { 76 | let vidId = "abcdefghijk"; 77 | let req = { query: { vid_id: vidId } }; 78 | buildYouTubeScope( 79 | vidId, 80 | { 81 | errors: [{ error: 100, message: "this error message" }] 82 | }, 83 | 500 84 | ); 85 | let resp = { 86 | status: status => { 87 | assert.equal(status, 500); 88 | }, 89 | send: data => { 90 | assert.equal(data, "Internal Error, see server logs"); 91 | done(); 92 | } 93 | }; 94 | youTubeTitle(req, resp); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/service/application.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _application = require("../../app/application"); 4 | const _routes = require("../../app/routes"); 5 | const express = require("express"); 6 | const request = require("supertest"); 7 | 8 | describe("App Setup", () => { 9 | let env, routes, logger, app; 10 | 11 | beforeEach(() => { 12 | app = express(); 13 | env = { get: n => n === "PORT" && 4700 }; 14 | routes = _routes.init({ applyToApp: () => {} }, () => {}); 15 | logger = { log: () => {} }; 16 | const stats = { handle: (req, res, next) => next() }; 17 | _application.init(env, routes, logger, app, stats); 18 | }); 19 | 20 | it("handles errors", () => { 21 | const a = request(app) 22 | .get("/test_error") 23 | .expect(500) 24 | .expect(/From Simple Error Handler/); 25 | }); 26 | 27 | it("can render a hello page", () => { 28 | request(app) 29 | .get("/") 30 | .expect(200) 31 | .expect(/Hello, from RCE Service/); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /test/service/cipher.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const cipher = require("../../app/utils/cipher"); 5 | 6 | describe("Cipher", function() { 7 | describe("encryption", function() { 8 | it("can encrypt and decrypt a string", function(done) { 9 | const cryptedString = cipher.encrypt("0.0.1"); 10 | assert.notEqual(cryptedString, "0.0.1"); 11 | 12 | const decryptedString = cipher.decrypt(cryptedString); 13 | assert.equal("0.0.1", decryptedString); 14 | 15 | done(); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/service/config.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, throws } = require("assert"); 4 | const _config = require("../../app/config"); 5 | const ConfigRequiredException = require("../../app/exceptions/ConfigRequiredException"); 6 | 7 | describe("config provider", () => { 8 | let env; 9 | let configs; 10 | let config; 11 | 12 | beforeEach(() => { 13 | env = { 14 | get(name) { 15 | if (name === "SECRET") { 16 | return "s3cret"; 17 | } 18 | } 19 | }; 20 | configs = { 21 | app: () => ({ 22 | name: "test app" 23 | }), 24 | security: env => ({ 25 | jwt: { 26 | secret: env.get("SECRET") 27 | } 28 | }) 29 | }; 30 | config = _config.init(env, configs); 31 | }); 32 | 33 | describe("get", () => { 34 | it("returns the value if exists", () => { 35 | equal(config.get("app.name"), "test app"); 36 | }); 37 | 38 | it("gets values from env", () => { 39 | equal(config.get("security.jwt.secret"), "s3cret"); 40 | }); 41 | 42 | it("returns null if it does not exist", () => { 43 | equal(config.get("app.other"), null); 44 | }); 45 | 46 | it("executes and returns fallback if it does not exist", () => { 47 | equal(config.get("app.other", () => "fallback"), "fallback"); 48 | }); 49 | }); 50 | 51 | describe("require", () => { 52 | it("returns the value if exists", () => { 53 | equal(config.require("app.name"), "test app"); 54 | }); 55 | 56 | it("throws EnvRequiredException if it does not exist", () => { 57 | throws(() => config.require("app.other"), ConfigRequiredException); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/service/container.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal } = require("assert"); 4 | const container = require("../../app/container"); 5 | 6 | describe("container", () => { 7 | it("returns a working container", () => { 8 | const provider = { init: () => ({}), singleton: true }; 9 | equal(container.make(provider), container.make(provider)); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /test/service/env.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, throws } = require("assert"); 4 | const _env = require("../../app/env"); 5 | const EnvRequiredException = require("../../app/exceptions/EnvRequiredException"); 6 | const { InvalidArgumentException } = require("node-exceptions"); 7 | 8 | describe("env provider", () => { 9 | let vars; 10 | let env; 11 | 12 | beforeEach(() => { 13 | vars = { 14 | KEY: "thekey", 15 | SECRET: "s3cret" 16 | }; 17 | env = _env.init(vars); 18 | }); 19 | 20 | describe("get", () => { 21 | it("returns the value if exists", () => { 22 | equal(env.get("KEY"), vars.KEY); 23 | }); 24 | 25 | it("returns null if it does not exist", () => { 26 | equal(env.get("OTHER"), null); 27 | }); 28 | 29 | it("executes and returns fallback if it does not exist", () => { 30 | equal(env.get("OTHER", () => "fallback"), "fallback"); 31 | }); 32 | 33 | it("throws an error if fallback is provided and is not a function", () => { 34 | throws(() => env.get("OTHER", "string"), InvalidArgumentException); 35 | }); 36 | }); 37 | 38 | describe("require", () => { 39 | it("returns the value if exists", () => { 40 | equal(env.require("KEY"), vars.KEY); 41 | }); 42 | 43 | it("throws EnvRequiredException if it does not exist", () => { 44 | throws(() => env.require("OTHER"), EnvRequiredException); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/service/exceptions/AuthRequiredException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, ok } = require("assert"); 4 | const AuthRequiredException = require("../../../app/exceptions/AuthRequiredException"); 5 | 6 | describe("AuthRequiredException", () => { 7 | describe("tokenMissing", () => { 8 | let err; 9 | 10 | beforeEach(() => { 11 | err = AuthRequiredException.tokenMissing(); 12 | }); 13 | 14 | it("returns an AuthRequiredException", () => { 15 | ok(err instanceof AuthRequiredException); 16 | }); 17 | 18 | it("has a status of 401", () => { 19 | equal(err.status, 401); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/service/exceptions/ConfigRequiredException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, ok } = require("assert"); 4 | const ConfigRequiredException = require("../../../app/exceptions/ConfigRequiredException"); 5 | 6 | describe("ConfigRequiredException", () => { 7 | describe("forPath", () => { 8 | let err; 9 | 10 | beforeEach(() => { 11 | err = ConfigRequiredException.forPath("a.b"); 12 | }); 13 | 14 | it("returns an ConfigRequiredException", () => { 15 | ok(err instanceof ConfigRequiredException); 16 | }); 17 | 18 | it("message includes config path", () => { 19 | ok(/a\.b/.test(err.message)); 20 | }); 21 | 22 | it("has a status of 500", () => { 23 | equal(err.status, 500); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/service/exceptions/EnvRequiredException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, ok } = require("assert"); 4 | const EnvRequiredException = require("../../../app/exceptions/EnvRequiredException"); 5 | 6 | describe("EnvRequiredException", () => { 7 | describe("forVar", () => { 8 | let err; 9 | 10 | beforeEach(() => { 11 | err = EnvRequiredException.forVar("KEY"); 12 | }); 13 | 14 | it("returns an EnvRequiredException", () => { 15 | ok(err instanceof EnvRequiredException); 16 | }); 17 | 18 | it("message includes var name", () => { 19 | ok(/KEY/.test(err.message)); 20 | }); 21 | 22 | it("has a status of 500", () => { 23 | equal(err.status, 500); 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/service/exceptions/HandledException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const HandledException = require("../../../app/exceptions/HandledException"); 4 | const express = require("express"); 5 | const request = require("supertest"); 6 | 7 | describe("HandledException", () => { 8 | let app, err; 9 | 10 | beforeEach(() => { 11 | app = express(); 12 | err = new HandledException("error message", 401); 13 | app.use(err.handle.bind(err)); 14 | }); 15 | 16 | it("returns error message and status code", done => { 17 | request(app) 18 | .get("/") 19 | .expect(401) 20 | .expect("error message") 21 | .end(done); 22 | }); 23 | 24 | it("defauls to 500 status code", done => { 25 | delete err.status; 26 | request(app) 27 | .get("/") 28 | .expect(500) 29 | .end(done); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/service/exceptions/InvalidMediaTrackException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, ok } = require("assert"); 4 | const InvalidMediaTrackException = require("../../../app/exceptions/InvalidMediaTrackException"); 5 | 6 | describe("InvalidMediaTrackException", () => { 7 | describe("missingLocale", () => { 8 | it("returns an InvalidMediaTrackException", () => { 9 | const err = InvalidMediaTrackException.missingLocale(); 10 | ok(err instanceof InvalidMediaTrackException); 11 | equal(err.status, 400); 12 | }); 13 | }); 14 | describe("missingLocale", () => { 15 | it("returns an InvalidMediaTrackException", () => { 16 | const err = InvalidMediaTrackException.missingLocale(); 17 | ok(err instanceof InvalidMediaTrackException); 18 | equal(err.status, 400); 19 | }); 20 | }); 21 | describe("badFormat", () => { 22 | it("returns an InvalidMediaTrackException", () => { 23 | const err = InvalidMediaTrackException.badFormat(); 24 | ok(err instanceof InvalidMediaTrackException); 25 | equal(err.status, 400); 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /test/service/exceptions/TokenInvalidException.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, ok } = require("assert"); 4 | const TokenInvalidException = require("../../../app/exceptions/TokenInvalidException"); 5 | 6 | describe("TokenInvalidException", () => { 7 | describe("before", () => { 8 | let err; 9 | 10 | beforeEach(() => { 11 | err = TokenInvalidException.before(); 12 | }); 13 | 14 | it("returns an TokenInvalidException", () => { 15 | ok(err instanceof TokenInvalidException); 16 | }); 17 | 18 | it("has correct code", () => { 19 | equal(err.code, "E_TOKEN_BEFORE_NBF"); 20 | }); 21 | 22 | it("has a status of 401", () => { 23 | equal(err.status, 401); 24 | }); 25 | }); 26 | 27 | describe("expired", () => { 28 | let err; 29 | 30 | beforeEach(() => { 31 | err = TokenInvalidException.expired(); 32 | }); 33 | 34 | it("returns an TokenInvalidException", () => { 35 | ok(err instanceof TokenInvalidException); 36 | }); 37 | 38 | it("has correct code", () => { 39 | equal(err.code, "E_TOKEN_AFTER_EXP"); 40 | }); 41 | 42 | it("has a status of 401", () => { 43 | equal(err.status, 401); 44 | }); 45 | }); 46 | 47 | describe("parse", () => { 48 | let err, originalError; 49 | 50 | beforeEach(() => { 51 | originalError = new Error("original"); 52 | err = TokenInvalidException.parse(originalError); 53 | }); 54 | 55 | it("returns an TokenInvalidException", () => { 56 | ok(err instanceof TokenInvalidException); 57 | }); 58 | 59 | it("has correct code", () => { 60 | equal(err.code, "E_TOKEN_PARSE_ERROR"); 61 | }); 62 | 63 | it("has a status of 401", () => { 64 | equal(err.status, 401); 65 | }); 66 | 67 | it("has reference to originalError", () => { 68 | equal(err.originalError, originalError); 69 | }); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /test/service/middleware/auth.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const AuthRequiredException = require("../../../app/exceptions/AuthRequiredException"); 5 | const _auth = require("../../../app/middleware/auth"); 6 | 7 | describe("auth middleware", () => { 8 | let auth, jwt, payload, wrapped, req, token; 9 | 10 | beforeEach(() => { 11 | jwt = "jwt"; 12 | req = { get: h => h === "Authorization" && `Bearer ${jwt}` }; 13 | payload = "payload"; 14 | wrapped = "wrapped"; 15 | token = { 16 | verify: async j => j === jwt && payload, 17 | wrap: async j => j === jwt && wrapped 18 | }; 19 | auth = _auth.init(token); 20 | }); 21 | 22 | it("calls next with AuthRequiredException if missing", async () => { 23 | req.get = () => null; 24 | await auth(req, {}, err => { 25 | assert.ok(err instanceof AuthRequiredException); 26 | assert.equal(err.status, 401); 27 | }); 28 | }); 29 | 30 | it("adds auth info to request", async () => { 31 | await auth(req, {}, () => { 32 | assert.equal(req.auth.token, jwt); 33 | assert.equal(req.auth.payload, payload); 34 | assert.equal(req.auth.wrappedToken, wrapped); 35 | }); 36 | }); 37 | 38 | it("calls next with exceptions thrown by token.verify", async () => { 39 | const verifyErr = {}; 40 | token.verify = () => { 41 | throw verifyErr; 42 | }; 43 | await auth(req, {}, err => { 44 | assert.equal(err, verifyErr); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/service/middleware/corsProtection.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const corsProtection = require("../../../app/middleware/corsProtection"); 4 | const assert = require("assert"); 5 | 6 | describe("corsProtection", function() { 7 | describe("validateOrigin", function() { 8 | it("allows everything", function(done) { 9 | corsProtection.validateOrigin("http://lvh.me", function(_, validated) { 10 | assert(validated); 11 | done(); 12 | }); 13 | }); 14 | 15 | it("approves undefined origins", function(done) { 16 | corsProtection.validateOrigin(undefined, function(_, validated) { 17 | assert(validated); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/service/middleware/errors.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const errors = require("../../../app/middleware/errors"); 4 | const assert = require("assert"); 5 | 6 | describe("Error Handling", function() { 7 | describe("onRavenData", function() { 8 | it("adds environment to tags", function() { 9 | let data = { tags: { some: "data" } }; 10 | errors.onRavenData(data, "testenv"); 11 | assert.equal(data.tags.some, "data"); 12 | assert.equal(data.tags.site, "testenv"); 13 | }); 14 | 15 | it("tags enviroment info when tags don't exist", function() { 16 | let data = {}; 17 | errors.onRavenData(data, "testenv"); 18 | assert.equal(data.tags.site, "testenv"); 19 | }); 20 | 21 | it("removes request env from data object (it's in the tags)", function() { 22 | let data = { request: { env: "production" } }; 23 | errors.onRavenData(data, "testenv"); 24 | assert.equal(data.request.env, undefined); 25 | }); 26 | }); 27 | 28 | describe("onMiddlewareData", function() { 29 | it("adds the request id to the extras hash", function() { 30 | let req = { id: "some-uuid" }; 31 | let data = { extra: { some: "data" } }; 32 | errors.onMiddlewareData(req, data); 33 | assert.equal(data.extra.some, "data"); 34 | assert.equal(data.extra.request_id, "some-uuid"); 35 | }); 36 | 37 | it("builds the extra hash if it's not there", function() { 38 | let req = { id: "some-uuid" }; 39 | let data = {}; 40 | errors.onMiddlewareData(req, data); 41 | assert.equal(data.extra.request_id, "some-uuid"); 42 | }); 43 | }); 44 | 45 | describe("applyToApp", function() { 46 | it("can hook middleware to the app without bombing", function() { 47 | let app = { 48 | middlewares: [], 49 | use: function(middleware) { 50 | this.middlewares.push(middleware); 51 | } 52 | }; 53 | assert.doesNotThrow(function() { 54 | errors.applyToApp(app, "https://fake:dsn@sentry.local:9000/app/1"); 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/service/middleware/healthcheck.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const sinon = require("sinon"); 4 | const { deepStrictEqual, strictEqual } = require("assert"); 5 | const fs = require("fs"); 6 | const healthcheck = require("../../../app/middleware/healthcheck"); 7 | 8 | describe("Healthcheck middleware", () => { 9 | it("returns a function", async () => { 10 | const fn = healthcheck(); 11 | strictEqual(typeof fn, "function"); 12 | }); 13 | 14 | describe("healthcheck returned function", () => { 15 | let fsStatStub, jsonStub, statusStub; 16 | let healthCheckFn; 17 | let res; 18 | 19 | beforeEach(() => { 20 | fsStatStub = sinon.stub(fs, "stat"); 21 | healthCheckFn = healthcheck(); 22 | jsonStub = sinon.stub(); 23 | statusStub = sinon.stub().returns({ json: jsonStub }); 24 | res = { status: statusStub }; 25 | }); 26 | 27 | afterEach(() => { 28 | fsStatStub.restore(); 29 | }); 30 | 31 | it("returns status 200 when components are healthy", async () => { 32 | fsStatStub.callsArgWith(1, null, { isDirectory: () => true }); 33 | 34 | await healthCheckFn(null, res, null); 35 | strictEqual(statusStub.firstCall.args[0], 200); 36 | }); 37 | 38 | it("returns json describing healthy components when healthy", async () => { 39 | const expectedComponent = { 40 | message: "Filesystem healthy", 41 | name: "Filesystem", 42 | status: 200 43 | }; 44 | 45 | fsStatStub.callsArgWith(1, null, { isDirectory: () => true }); 46 | 47 | await healthCheckFn(null, res, null); 48 | deepStrictEqual( 49 | jsonStub.firstCall.args[0].components[0], 50 | expectedComponent 51 | ); 52 | }); 53 | 54 | it("returns status 503 when components are unhealthy", async () => { 55 | fsStatStub.callsArgWith(1, null, { isDirectory: () => false }); 56 | 57 | await healthCheckFn(null, res, null); 58 | strictEqual(statusStub.firstCall.args[0], 503); 59 | }); 60 | 61 | it("returns json describing unhealthy components when unhealthy", async () => { 62 | const expectedComponent = { 63 | message: "Filesystem in unexpected state", 64 | name: "Filesystem", 65 | status: 503 66 | }; 67 | 68 | fsStatStub.callsArgWith(1, null, { isDirectory: () => false }); 69 | 70 | await healthCheckFn(null, res, null); 71 | deepStrictEqual( 72 | jsonStub.firstCall.args[0].components[0], 73 | expectedComponent 74 | ); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/service/middleware/requestLogs.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const reqLogs = require("../../../app/middleware/requestLogs"); 4 | const assert = require("assert"); 5 | 6 | describe("requestLogging", function() { 7 | it("puts the request id at the front of the logs", done => { 8 | let stream = { 9 | data: [], 10 | write: stuff => { 11 | stream.data.push(stuff); 12 | } 13 | }; 14 | let middleware = reqLogs.middleware(stream, true); 15 | let req = { 16 | headers: {}, 17 | method: "GET", 18 | path: "/my/api/call", 19 | id: "1234567890" 20 | }; 21 | let resp = {}; 22 | middleware(req, resp, function() { 23 | assert.ok(/^\[1234567890/.test(stream.data[0])); 24 | done(); 25 | }); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /test/service/middleware/stats.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const StatsMiddleware = require("../../../app/middleware/stats"); 4 | const dgram = require("dgram"); 5 | const getPort = require("get-port"); 6 | const fakeConfig = require("../../support/fakeConfig"); 7 | const { EventEmitter } = require("events"); 8 | const sinon = require("sinon"); 9 | const assert = require("assert"); 10 | 11 | describe("StatsMiddleware", () => { 12 | let statsServer, req, res, envOverrides, clock, next; 13 | 14 | beforeEach(async () => { 15 | clock = sinon.useFakeTimers(); 16 | next = sinon.spy(); 17 | envOverrides = { 18 | STATSD_HOST: "127.0.0.1", 19 | STATSD_PORT: await getPort(), 20 | STATS_PREFIX: "prefix", 21 | CG_ENVIRONMENT: "env", 22 | DOG_TAGS: "false" 23 | }; 24 | req = { actionKey: "foo" }; 25 | res = new EventEmitter(); 26 | statsServer = dgram.createSocket("udp4"); 27 | statsServer.bind(envOverrides.STATSD_PORT); 28 | }); 29 | 30 | afterEach(() => { 31 | clock.restore(); 32 | statsServer.removeAllListeners(); 33 | statsServer.close(); 34 | }); 35 | 36 | const run = () => { 37 | const mw = new StatsMiddleware(fakeConfig(envOverrides)); 38 | mw.handle(req, res, next); 39 | }; 40 | 41 | const message = (nth = 1) => 42 | new Promise((resolve, reject) => { 43 | statsServer.once("message", msg => { 44 | statsServer.removeAllListeners(); 45 | resolve(msg.toString()); 46 | }); 47 | statsServer.once("error", err => { 48 | statsServer.removeAllListeners(); 49 | reject(err); 50 | }); 51 | }).then(msg => { 52 | if (nth > 1) { 53 | return message(nth - 1); 54 | } 55 | return msg; 56 | }); 57 | 58 | const assertMsg = async (expected, nth) => { 59 | const pending = message(nth); 60 | res.emit("finish"); 61 | const msg = await pending; 62 | assert.equal(msg, expected); 63 | }; 64 | 65 | it("calls next synchronously", () => { 66 | run(); 67 | sinon.assert.calledOnce(next); 68 | }); 69 | 70 | describe("statsd", () => { 71 | it("logs request timing", async () => { 72 | run(); 73 | clock.tick(100); 74 | await assertMsg("prefix.env.request.foo.response_time:100|ms", 2); 75 | }); 76 | 77 | it("includes req timers", async () => { 78 | req.timers = { a: 1 }; 79 | run(); 80 | await assertMsg("prefix.env.request.foo.a:1|ms", 3); 81 | }); 82 | 83 | it("includes status code counter", async () => { 84 | res.statusCode = 200; 85 | run(); 86 | await assertMsg("prefix.env.request.foo.status_code.200:1|c"); 87 | }); 88 | 89 | it("includes unknown_status", async () => { 90 | run(); 91 | await assertMsg("prefix.env.request.foo.status_code.unknown_status:1|c"); 92 | }); 93 | }); 94 | 95 | describe("datadog", () => { 96 | beforeEach(() => { 97 | envOverrides.DOG_TAGS = "{}"; 98 | }); 99 | 100 | it("logs request timing", async () => { 101 | run(); 102 | clock.tick(100); 103 | await assertMsg("prefix.env.request.response_time:100|ms|#action:foo", 2); 104 | }); 105 | 106 | it("includes global tags", async () => { 107 | envOverrides.DOG_TAGS = '{"a":1}'; 108 | run(); 109 | await assertMsg( 110 | "prefix.env.request.response_time:0|ms|#a:1,action:foo", 111 | 2 112 | ); 113 | }); 114 | 115 | it("includes req timers", async () => { 116 | req.timers = { a: 1 }; 117 | run(); 118 | await assertMsg("prefix.env.request.a:1|ms|#action:foo", 3); 119 | }); 120 | 121 | it("includes status code counter", async () => { 122 | res.statusCode = 200; 123 | run(); 124 | await assertMsg("prefix.env.request.response:1|c|#action:foo,status:200"); 125 | }); 126 | 127 | it("includes unknown_status", async () => { 128 | run(); 129 | await assertMsg( 130 | "prefix.env.request.response:1|c|#action:foo,status:unknown_status" 131 | ); 132 | }); 133 | }); 134 | 135 | describe("cleanup", () => { 136 | beforeEach(() => { 137 | res.removeListener = sinon.spy(); 138 | run(); 139 | }); 140 | 141 | it("cleans up on finish", () => { 142 | res.emit("finish"); 143 | sinon.assert.called(res.removeListener); 144 | }); 145 | 146 | it("cleans up on error", () => { 147 | res.emit("error"); 148 | sinon.assert.called(res.removeListener); 149 | }); 150 | 151 | it("cleans up on close", () => { 152 | res.emit("close"); 153 | sinon.assert.called(res.removeListener); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/service/token.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const sinon = require("sinon"); 4 | const assert = require("assert"); 5 | const TokenInvalidException = require("../../app/exceptions/TokenInvalidException"); 6 | const _token = require("../../app/token"); 7 | 8 | const CANVAS_TOKEN = 9 | "ZXlKaGJHY2lPaUprYVhJaUxDSmxibU1pT2lKQk1qVTJSME5OSW4wLi5iM2hpanZGMUxWWGNERVlzLk1uMWdpMDVuR0xQVDBJQlVzNkdKOVdjYWVCR0pHTXZKMHV1S2JrS0RXTm5zQ01vUldxbDNMcTExSjRsbjJKRkJLTWpRVFpBd1F2QkROVFdveUoyRnFiYVJIbzZpVlI2MTlIRUo5ZkVEdXBOd3llR1pkY1RwQzVST3g0VmRkNlBBQk1uYWpjWVJDY3VCcDlJZm5ERjFDRHgyYVFJNGZBVDJpeEJ2WnR2RFh0OE5ZOGVQZFU1VW9Vbm9YY0xucEROMWF2UFlaNkFxNnBIOVFSVWpvdFdpU29Mc2NKLXFIVEQyWl8wYnN3MWJfemFaUlVRLUN5Q2F1OHBOZzBhWG1BMlVkV2pRMGVaanZNRmJjazdFMjB0V2o5RlNQblFvZHBqWDE5Z0VzZkVkaHpKd2kyYXhlalFSSktCT1lVcVo5cXNLZWRJSUxUWkR0UFhyZFE0UF83V2VkTU1MdTJpVHAyYWN5MWlzLW5qMDBBbm9vdDNQN0lRaTRwcDRORkFOUDdRRy1jMlM0SzBsSDc1dUxsWUZxeS1XWDlOUHZkWW94UjdFamdUcjVMay1PNkhFbFdiQjJudzdLeUs2SzBMWjNsVXJwMHlUVXltYkRVbHZxZ2lGc093UlVqTjBGMnU5a0FhMUoxeFgxcTFVa1kzYmpzU2pFS2pRenVlZUR6UlVkeHFoUHRNLUFDdV9neFBHRy1VTnlPRFFJa0lrRnNzSHNwZWY4dUlFTWhZbzFwUWEyLTBOTjY3ZlRjUG9leHhMVlZkUW9EWGVuamVpX0s2OTNGSTRzSmRFYTlPYzZMLURsbzhKOVZEM2ZiUjFmQVlibzI1QWtXaExqbC1TQ3BCeHBjTzR6Qk4tVFh2NHp2RHRLS0dUVjdQWERDVkdNVWlJS2ZBM2o2NVkyR1hhYkZndENTVTJIOVFfUHhwWlJhR0Q2ZXBqaWdVNkVraU1hLUsyX2VQXzR3T3RZMDhlUFNZdW5XRmJVZzE5RGJwekV2WFVzTUVsSTAtMGZEV2xHTXhzY2pKT1h3T2cwWGZzQlNSajBQUS5lVndYaGc2UTZzVjNZWXdSbGtwa19n"; 10 | 11 | describe("token", () => { 12 | let clock, fakeConfig, token; 13 | 14 | beforeEach(() => { 15 | fakeConfig = { 16 | require(key) { 17 | switch (key) { 18 | case "token.signingSecret": 19 | return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; 20 | case "token.encryptionSecret": 21 | return "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; 22 | } 23 | } 24 | }; 25 | clock = sinon.useFakeTimers(1000); 26 | token = _token.init(fakeConfig); 27 | }); 28 | 29 | afterEach(() => { 30 | clock.restore(); 31 | }); 32 | 33 | it("can decrypt and validate canvas jwt", async () => { 34 | const payload = await token.payload(CANVAS_TOKEN); 35 | assert.equal(payload.iss, "Canvas"); 36 | }); 37 | 38 | it("create returns a valid jwt", async () => { 39 | const jwt = await token.create({ test: "create" }); 40 | const payload = await token.payload(jwt); 41 | assert.equal(payload.test, "create"); 42 | }); 43 | 44 | it("payload returns payload without validating", async () => { 45 | const jwt = await token.create({ test: "payload", exp: 0 }); 46 | const payload = await token.payload(jwt); 47 | assert.equal(payload.test, "payload"); 48 | }); 49 | 50 | it("verify throws TokenInvalidException if after exp", async () => { 51 | const jwt = await token.create({ test: "verify", exp: 0 }); 52 | return await token.verify(jwt).catch(error => { 53 | assert.ok(error instanceof TokenInvalidException); 54 | assert.equal(error.code, "E_TOKEN_AFTER_EXP"); 55 | }); 56 | }); 57 | 58 | it("verify throws TokenInvalidException before nbf", async () => { 59 | const jwt = await token.create({ test: "verify", nbf: 2 }); 60 | return await token.verify(jwt).catch(error => { 61 | assert.ok(error instanceof TokenInvalidException); 62 | assert.equal(error.code, "E_TOKEN_BEFORE_NBF"); 63 | }); 64 | }); 65 | 66 | it("very returns payload with valid dates", async () => { 67 | const jwt = await token.create({ test: "verify", exp: 2 }); 68 | const payload = await token.verify(jwt); 69 | assert.equal(payload.test, "verify"); 70 | }); 71 | 72 | it("wrap returns jws with decoded user token", async () => { 73 | const decoded = "usertoken"; 74 | const jwt = token._encode(decoded); 75 | const verified = await token._verify(token._decode(await token.wrap(jwt))); 76 | const payload = JSON.parse(verified.payload); 77 | assert.equal(payload.user_token, decoded); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/service/utils/object.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const { equal, deepEqual } = require("assert"); 4 | const object = require("../../../app/utils/object"); 5 | 6 | describe("object utils", () => { 7 | describe("getByPath", () => { 8 | let obj; 9 | 10 | beforeEach(() => { 11 | obj = { a: { b: { c: 47 } } }; 12 | }); 13 | 14 | it("finds nested keys", () => { 15 | equal(object.getByPath("a.b.c", obj), 47); 16 | deepEqual(object.getByPath("a.b", obj), { c: 47 }); 17 | }); 18 | 19 | it("returns null for keys of non-objects", () => { 20 | equal(object.getByPath("a.b.c.d"), null); 21 | }); 22 | 23 | it("returns nul for for keys that don't exist", () => { 24 | equal(object.getByPath("a.c"), null); 25 | }); 26 | }); 27 | 28 | describe("getArrayQueryParam", () => { 29 | it("returns array if param is an array", () => { 30 | const list = object.getArrayQueryParam(["a", "b"]); 31 | deepEqual(list, ["a", "b"]); 32 | }); 33 | 34 | it("return array from comma separated string", () => { 35 | const list = object.getArrayQueryParam("a,b"); 36 | deepEqual(list, ["a", "b"]); 37 | }); 38 | 39 | it("returns the empty string if param is undefined", () => { 40 | const list = object.getArrayQueryParam(); 41 | equal(list, ""); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /test/service/utils/search.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const search = require("../../../app/utils/search"); 5 | 6 | describe("search(query)", () => { 7 | let query = new Object(); 8 | 9 | afterEach(() => { 10 | query.search_term = undefined; 11 | }); 12 | 13 | it("returns empty string if query search term is undefined", () => { 14 | query.search_term = undefined; 15 | const getSearchResult = search.getSearch(query); 16 | assert.strictEqual(getSearchResult, ""); 17 | }); 18 | 19 | it("returns properly encoded ASCII characters from query search term", () => { 20 | const searchText = "some search term"; 21 | const encodedSearchText = encodeURIComponent(searchText); 22 | query.search_term = searchText; 23 | const getSearchResult = search.getSearch(query); 24 | assert(getSearchResult.endsWith(encodedSearchText)); 25 | }); 26 | 27 | it("returns properly encoded special characters from query search term", () => { 28 | const specialCharsSearchText = "❤️ or Œ åßÑ"; 29 | const encodedSearchText = encodeURIComponent(specialCharsSearchText); 30 | query.search_term = specialCharsSearchText; 31 | const getSearchResult = search.getSearch(query); 32 | assert.ok( 33 | getSearchResult.endsWith(encodedSearchText), 34 | `Expected ${getSearchResult} to end with ${encodedSearchText}` 35 | ); 36 | }); 37 | 38 | it("does not encode again an already encoded search term", () => { 39 | const encodedSearchText = encodeURIComponent("some search text"); 40 | query.search_term = encodedSearchText; 41 | const getSearchResult = search.getSearch(query); 42 | assert.ok( 43 | getSearchResult.endsWith(encodedSearchText), 44 | `Expected ${getSearchResult} to end with ${encodedSearchText}` 45 | ); 46 | }); 47 | 48 | it("does not return an error for malformed URIs", () => { 49 | const searchTerm = "80%"; 50 | const encodedSearchText = encodeURIComponent(searchTerm); 51 | query.search_term = searchTerm; 52 | 53 | const getSearchResult = search.getSearch(query); 54 | assert(getSearchResult.endsWith(encodedSearchText)); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/service/utils/sign.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const sign = require("../../../app/utils/sign"); 5 | const sinon = require("sinon"); 6 | 7 | describe("sign util", () => { 8 | let originalSecret, clock; 9 | 10 | beforeEach(() => { 11 | originalSecret = process.env.ECOSYSTEM_SECRET; 12 | process.env.ECOSYSTEM_SECRET = "testsecret"; 13 | clock = sinon.useFakeTimers(); 14 | }); 15 | 16 | afterEach(() => { 17 | process.env.ECOSYSTEM_SECRET = originalSecret; 18 | clock.restore(); 19 | }); 20 | 21 | it("verify returns null if null", () => { 22 | assert.strictEqual(sign.verify(null), null); 23 | }); 24 | 25 | it("verify returns null if undefined", () => { 26 | assert.strictEqual(sign.verify(undefined), null); 27 | }); 28 | 29 | it("verify returns null if invalid", () => { 30 | assert.strictEqual(sign.verify("invalid"), null); 31 | }); 32 | 33 | it("verify returns string if valid", () => { 34 | const val = "valuetosign"; 35 | const signed = sign.sign(val); 36 | assert.strictEqual(sign.verify(signed), val); 37 | }); 38 | 39 | it("verify returns null if expired", () => { 40 | const val = "valuetosign"; 41 | const signed = sign.sign(val, 1); 42 | clock.tick(2000); 43 | assert.strictEqual(sign.verify(signed), null); 44 | }); 45 | 46 | it("verify returns null if expired", () => { 47 | const val = "valuetosign"; 48 | const signed = sign.sign(val, 1); 49 | clock.tick(1000); 50 | assert.strictEqual(sign.verify(signed), null); 51 | }); 52 | 53 | it("verify returns null if tampered with", () => { 54 | const val = "valuetosign"; 55 | const signed = sign.sign(val); 56 | const parts = signed.split("."); 57 | const payload = JSON.parse(Buffer.from(parts[1], "base64")); 58 | payload.val = "othervalue"; 59 | parts[1] = Buffer.from(JSON.stringify(payload)).toString("base64"); 60 | const invalid = parts.join("."); 61 | assert.strictEqual(sign.verify(invalid), null); 62 | }); 63 | 64 | it("expires in 24 hours by default", () => { 65 | const val = "valuetosign"; 66 | const signed = sign.sign(val); 67 | clock.tick(24 * 60 * 60 * 1000 - 1); 68 | assert.strictEqual(sign.verify(signed), val); 69 | clock.tick(1); 70 | assert.strictEqual(sign.verify(signed), null); 71 | }); 72 | 73 | it("sign allows setting expiration seconds", () => { 74 | const val = "valuetosign"; 75 | const signed = sign.sign(val, 3); 76 | clock.tick(2000); 77 | assert.strictEqual(sign.verify(signed), val); 78 | clock.tick(1000); 79 | assert.strictEqual(sign.verify(signed), null); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /test/shared/mimeClass.test.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const assert = require("assert"); 4 | const { fileEmbed, mimeClass } = require("../../shared/mimeClass"); 5 | 6 | describe("fileEmbed", () => { 7 | it("defaults to file", () => { 8 | assert.equal(fileEmbed({}).type, "file"); 9 | }); 10 | 11 | it("uses content-type to identify video and audio", () => { 12 | let video = fileEmbed({ "content-type": "video/mp4" }); 13 | let audio = fileEmbed({ "content-type": "audio/mpeg" }); 14 | assert.equal(video.type, "video"); 15 | assert.equal(video.id, "maybe"); 16 | assert.equal(audio.type, "audio"); 17 | assert.equal(audio.id, "maybe"); 18 | }); 19 | 20 | it("returns media entry id if provided", () => { 21 | let video = fileEmbed({ 22 | "content-type": "video/mp4", 23 | media_entry_id: "42" 24 | }); 25 | assert.equal(video.id, "42"); 26 | }); 27 | 28 | it("returns maybe in place of media entry id if not provided", () => { 29 | let video = fileEmbed({ "content-type": "video/mp4" }); 30 | assert.equal(video.id, "maybe"); 31 | }); 32 | 33 | it("picks scribd if there is a preview_url", () => { 34 | let scribd = fileEmbed({ preview_url: "some-url" }); 35 | assert.equal(scribd.type, "scribd"); 36 | }); 37 | 38 | it("uses content-type to identify images", () => { 39 | let image = fileEmbed({ 40 | "content-type": "image/png", 41 | canvadoc_session_url: "some-url" 42 | }); 43 | assert.equal(image.type, "image"); 44 | }); 45 | }); 46 | 47 | describe("mimeClass", () => { 48 | it("returns mime_class attribute if present", () => { 49 | let mime_class = "wooper"; 50 | assert.equal(mimeClass({ mime_class: mime_class }), mime_class); 51 | }); 52 | 53 | it("returns value corresponding to provided `content-type`", () => { 54 | assert.equal(mimeClass({ "content-type": "video/mp4" }), "video"); 55 | }); 56 | 57 | it("returns value corresponding to provided `type`", () => { 58 | assert.equal(mimeClass({ type: "video/mp4" }), "video"); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/support/fakeConfig.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _config = require("../../app/config"); 4 | const _env = require("../../app/env"); 5 | const configs = require("../../config"); 6 | 7 | function fakeConfig(envOverrides = {}, configOverrides = {}) { 8 | const env = _env.init({ ...process.env, ...envOverrides }); 9 | const conf = { ...configs, ...configOverrides }; 10 | return _config.init(env, conf); 11 | } 12 | 13 | module.exports = fakeConfig; 14 | -------------------------------------------------------------------------------- /test/support/setup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | global.fetch = require("node-fetch"); 4 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | # ignore all files in this dir... 2 | * 3 | 4 | # ... except for this one. 5 | !.gitignore 6 | --------------------------------------------------------------------------------