├── .circleci └── config.yml ├── .gitignore ├── Biomefile ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── disable-on-a-fork.md ├── package.json ├── src ├── analytics.js ├── auth │ ├── serialize.js │ └── strategy.js ├── helpers │ ├── errors.js │ ├── mixpanel.js │ └── route.js ├── models.js ├── root-file.html ├── routes │ ├── checkRepo.js │ ├── helpers.js │ ├── links │ │ ├── create │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── delete │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── enable │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── get-operations │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── get │ │ │ ├── index.js │ │ │ └── test.js │ │ ├── list │ │ │ ├── index.js │ │ │ └── test.js │ │ └── update │ │ │ ├── helpers.js │ │ │ ├── index.js │ │ │ └── test.js │ ├── webhook │ │ ├── manual │ │ │ ├── index.js │ │ │ └── test.js │ │ └── status │ │ │ ├── index.js │ │ │ └── test.js │ └── whoami │ │ └── index.js ├── server.js └── test-helpers │ ├── .DS_Store │ ├── create-database-model-instances.js │ ├── create-mock-github-instance.js │ ├── issue-request.js │ └── mock-model.js └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | docker: 5 | - image: circleci/node:8.9 6 | steps: 7 | - checkout 8 | 9 | # Restore cache from last test run 10 | - restore_cache: 11 | keys: 12 | - v1-dependencies-{{ checksum "package.json" }} 13 | # fallback to using the latest cache if no exact match is found 14 | - v1-dependencies- 15 | 16 | - run: 17 | name: "Install dependencies" 18 | command: yarn 19 | 20 | # Save cache for a future test run 21 | - save_cache: 22 | paths: 23 | - node_modules 24 | key: v1-dependencies-{{ checksum "package.json" }} 25 | 26 | - run: 27 | name: "Run tests" 28 | command: yarn test-ci 29 | 30 | build: 31 | docker: 32 | - image: circleci/node:8.9 33 | steps: 34 | - checkout 35 | - setup_remote_docker 36 | 37 | # Build docker container for backstroke server 38 | - run: 39 | name: Build container image 40 | command: 'docker build -t backstroke/server:latest .' 41 | - run: 42 | name: Login to Docker Hub 43 | command: "docker login -u $DOCKER_USER -p $DOCKER_PASS" 44 | - run: 45 | name: Push container image 46 | command: 'docker push backstroke/server:latest' 47 | 48 | workflows: 49 | version: 2 50 | build_and_test: 51 | jobs: 52 | - test 53 | - build: 54 | requires: 55 | - test 56 | filters: 57 | branches: 58 | only: master 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .*.sw* 3 | docker-compose.yml 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /Biomefile: -------------------------------------------------------------------------------- 1 | # This is a Biomefile. It helps you create an environment to run this app. 2 | # More info at https://github.com/1egoman/biome 3 | name=backstroke 4 | PORT=8000 5 | MONGODB_URI= 6 | CORS_ORIGIN_REGEXP='(localhost|127\.0\.0\.1)' 7 | 8 | GITHUB_CLIENT_ID= 9 | GITHUB_CLIENT_SECRET= 10 | GITHUB_CALLBACK_URL= 11 | GITHUB_TOKEN= 12 | 13 | SESSION_SECRET=hello world 14 | USE_MIXPANEL= 15 | 16 | API_URL=http://localhost:$PORT 17 | APP_URL= 18 | ROOT_URL= 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Subcommands 2 | 3 | - `yarn test`: Run all tests interactively with jest. 4 | - `yarn coverage`: Get coverage statistics with jest. 5 | - `yarn migrate`: Perform a database migration to get the database tables into the expected state 6 | given the state of the models. 7 | - `yarn shell`: Opens a REPL with a number of helpful utilities, including: 8 | - `redis` is an instance of node-redis that is connected to the redis instance used by the server 9 | and worker. 10 | - `Link` is an instance of the `Link` model that is attached to the database. Useful for performing 11 | database operations on links. 12 | - `User` is an instance of the `User` model that is attached to the database. Useful for performing 13 | database operations on links. 14 | - `WebhookQueue` is an object containing `.push(item)` and `.pop()` functions used to add or 15 | remove items from the queue. 16 | - `WebhookStatusStore` is an object containing `.set(id, data)` and `.get(id)` functions used to 17 | get the status of a webhook operation given its id. 18 | - `yarn manual-job`: Manually issue the webhook job. Primarily used if working on the webhook job 19 | and one wants to run the job over and over. 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:8.9-alpine 2 | MAINTAINER Ryan Gaus "rgaus.net" 3 | 4 | # Create a user to run the app and setup a place to put the app 5 | COPY . /app 6 | RUN rm -rf /app/node_modules 7 | 8 | WORKDIR /app 9 | 10 | # Set up packages 11 | RUN yarn 12 | 13 | # Run the app 14 | CMD yarn start 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Ryan Gaus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # I don't have the bandwidth to maintain Backstroke anymore. 2 | 3 | It's a project that has done fairly well, and many people use it and find it helpful. But, it's also a project that does too much for a single person to maintain, and it's got a number of moving parts that all have a tendency to break at the worst time (usually when I'm away from a computer). For a while, I found it fun to maintain, but over the past year it's turned into a chore, and I don't really have an interest in keeping it running. 4 | 5 | Normally, when people shut things down, an alternative is usually provided. One alternative (found by @Freekers) is https://github.com/wei/pull, which looks pretty good - I'd recommend giving it a try. Another alternative if pull doesn't solve your problem is to set up a small server that either listens for a webhook (like backstroke classic) or polls the main branch on an upstream for changs. Then, use the Github API to create a new pull request. Backstroke classic, an example of this sort of approach, can be found here: https://github.com/backstrokeapp/legacy~ 6 | 7 | If anyone has an interest in taking over the project, please let me know. I'd be more than willing to pass it off to you and walk you through setting it up for development and deployment. If there isn't any interest, I'll likely shut it off later in the year once Backstroke's sponsored digitalocean credit runs out. 8 | 9 | Sorry for this being so abrupt. To be honest, I've been thinking about doing this for a while and I should have been a bit more honest in mentioning this earlier. 10 | 11 | - Ryan, @1egoman 12 | 13 | --- 14 | 15 | 16 | ![Backstroke](https://backstroke.us/assets/img/logo.png) 17 | Backstroke 18 | === 19 | A Github bot to keep a fork updated with any changes made to its upstream. [Visit our Website](https://backstroke.co) 20 | 21 | [![Website](https://img.shields.io/website-up-down-green-red/http/backstroke.co.svg?maxAge=2592000)](https://backstroke.co) 22 | 23 | ## Add Backstroke to a repository 24 | 25 | 1. Go to [backstroke.co](https://backstroke.co), and sign in with your Github account. 26 | 2. Click `Create new link` 27 | 3. Add a source repo under the text `Upstream`. 28 | 4. Select a destination repository - either all aforks of the upstream, or a particular form by 29 | clicking `one fork` and typing its name. 30 | 5. Click `Save`. If you push a change to the repo listed under `Upstream`, you'll 31 | get a pull request with any updates in the repo(s) under `Fork`! 32 | 33 | ## FAQ 34 | - **I don't see any pull requests on the upstream....**: Pull requests are 35 | always proposed on forks. Take a look there instead. 36 | 37 | - **I didn't sign up for this and now I'm getting pull requests. What's going on?**: Backstroke only creates pull requests in two cases: 38 | - A link was created by an upstream maintainer, and your fork has the `backstroke-sync` issue label on it. 39 | - A link was created by a user that has write access on Github to the fork that syncs to the fork only. 40 | 41 | If you're receiving unsolicited pull requests and the `backstroke-sync` issue label hasn't been created on your fork, ensure that none of the contributors of your repository have added Backstroke to your fork. If you want some help, create an issue and we can figure out what's going on. We're really sorry that Backstroke could possibly create pull request spam - we've tried to build Backstroke in such a way where this shouldn't happen but sometimes we may have forgotten something or didn't consider a possible situation. 42 | 43 | - **Why isn't Backstroke working?**: Take a look at the webhook response logs. Most likely, you'll see an error. Otherwise, open an issue. 44 | 45 | - **Does Backstroke work outside of Github?**: Not yet. 46 | 47 | ------- 48 | By [Ryan Gaus](http://rgaus.net) 49 | -------------------------------------------------------------------------------- /disable-on-a-fork.md: -------------------------------------------------------------------------------- 1 | # Disable Backstroke on a fork 2 | 3 | 1. Take a look at any pull request that backstroke has made. Ensure that it is currently open. 4 | 2. Create a label titled `optout` - all one word. 5 | 3. Give the previously specified pull request the label `optout`. 6 | 4. Close the pull request. Backstroke shouldn't bug you anymore. 7 | 8 | If you're removing backstroke for a specific reason, please 9 | [tell me more](https://github.com/backstrokeapp/server/issues/new). 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@backstroke/server", 3 | "version": "2.3.0", 4 | "description": "", 5 | "main": "src/server.js", 6 | "dependencies": { 7 | "@backstroke/worker": "^1.0.2", 8 | "babel-cli": "^6.10.1", 9 | "babel-plugin-istanbul": "^2.0.1", 10 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 11 | "babel-plugin-transform-runtime": "^6.23.0", 12 | "babel-preset-es2015": "^6.24.1", 13 | "babel-preset-es2017": "^6.24.1", 14 | "babel-preset-react": "^6.11.1", 15 | "babelify": "^7.3.0", 16 | "bluebird": "^3.4.3", 17 | "body-parser": "^1.15.2", 18 | "bootstrap": "^3.3.6", 19 | "cors": "^2.8.3", 20 | "debug": "^2.6.3", 21 | "del": "^2.2.2", 22 | "express": "^4.14.0", 23 | "express-session": "^1.14.1", 24 | "github": "^8.1.0", 25 | "mixpanel": "^0.7.0", 26 | "mocha": "^2.5.3", 27 | "morgan": "^1.9.0", 28 | "node-fetch": "^1.7.1", 29 | "nyc": "^8.1.0", 30 | "passport": "^0.3.2", 31 | "passport-github2": "^0.1.10", 32 | "pg": "^7.1.0", 33 | "pg-connection-string": "^0.1.3", 34 | "pg-hstore": "^2.3.2", 35 | "raven": "^2.1.1", 36 | "request": "^2.81.0", 37 | "rsmq": "^0.8.2", 38 | "sequelize": "^4.4.2", 39 | "sequelize-cli": "^2.8.0", 40 | "session-file-store": "^1.1.1", 41 | "sinon": "^2.3.6", 42 | "uuid": "^3.0.1", 43 | "watchify": "^3.7.0" 44 | }, 45 | "scripts": { 46 | "start": "babel-node src/server.js", 47 | "start-dev": "nodemon --exec 'babel-node src/server.js'", 48 | "test": "NODE_ENV=test jest --watch", 49 | "test-ci": "NODE_ENV=test CI=true jest", 50 | "coverage": "NODE_ENV=test nyc --all --reporter=lcov --reporter=text npm test", 51 | "coverageopen": "open coverage/lcov-report/index.html", 52 | "migrate": "babel-node src/models.js migrate", 53 | "shell": "babel-node src/models.js shell", 54 | "manual-job": "babel-node src/models.js manual-job" 55 | }, 56 | "babel": { 57 | "presets": [ 58 | "es2015", 59 | "es2017", 60 | "react" 61 | ], 62 | "plugins": [ 63 | "transform-object-rest-spread", 64 | "transform-runtime" 65 | ], 66 | "env": { 67 | "test": { 68 | "plugins": [ 69 | "istanbul" 70 | ] 71 | } 72 | } 73 | }, 74 | "jest": { 75 | "collectCoverageFrom": [ 76 | "src/*.js", 77 | "src/**/*.js" 78 | ] 79 | }, 80 | "author": "Ryan Gaus ", 81 | "license": "MIT", 82 | "devDependencies": { 83 | "faker": "^4.1.0", 84 | "jest": "^20.0.4", 85 | "nodemon": "^1.11.0" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/analytics.js: -------------------------------------------------------------------------------- 1 | import Mixpanel from 'mixpanel'; 2 | let mixpanel; 3 | if (process.env.USE_MIXPANEL) { 4 | mixpanel = Mixpanel.init(process.env.USE_MIXPANEL); 5 | } 6 | 7 | export function trackWebhook(link) { 8 | process.env.USE_MIXPANEL && mixpanel.track('Webhook', { 9 | "Link Id": link._id, 10 | "From Repo Name": link.upstream ? link.upstream.name : null, 11 | "From Repo Provider": link.upstream ? link.upstream.provider : null, 12 | "To Repo Name": link.fork ? (link.fork.name || link.fork.type) : null, 13 | "To Repo Provider": link.fork ? link.fork.provider : null, 14 | }); 15 | } 16 | -------------------------------------------------------------------------------- /src/auth/serialize.js: -------------------------------------------------------------------------------- 1 | // Attach serialization to the user 2 | export default function userSerialization(User, passport) { 3 | passport.serializeUser(function(user, done) { 4 | done(null, user.id); 5 | }); 6 | passport.deserializeUser(function(id, done) { 7 | User.findById(id) 8 | .then(model => done(null, model)) 9 | .catch(err => done(err)); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/strategy.js: -------------------------------------------------------------------------------- 1 | import GithubStrategy from 'passport-github2'; 2 | 3 | import Debug from 'debug'; 4 | const debug = Debug('backstroke:auth'); 5 | 6 | export default function strategy(User) { 7 | return new GithubStrategy({ 8 | clientID: process.env.GITHUB_CLIENT_ID, 9 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 10 | callbackURL: process.env.GITHUB_CALLBACK_URL, 11 | }, function(accessToken, refreshToken, profile, cb) { 12 | debug('PROVIDER ID %s', profile.id); 13 | 14 | // Register a new user. 15 | User.register(profile, accessToken).then(model => { 16 | debug('LOGGED IN USER %o', model); 17 | cb(null, model); 18 | }).catch(err => { 19 | if (err.name === 'ValidationError') { 20 | cb(JSON.stringify({ok: false, error: 'validation', context: err.context, issues: err.codes})); 21 | } else { 22 | cb(err); 23 | }; 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/helpers/errors.js: -------------------------------------------------------------------------------- 1 | export function NoSuchLinkError(message) { 2 | this.message = message; 3 | this.name = "NoSuchLinkError"; 4 | Error.captureStackTrace(this, NoSuchLinkError); 5 | } 6 | export function PremiumRequiresPaymentError(message) { 7 | this.message = message; 8 | this.name = "PremiumRequiresPaymentError"; 9 | Error.captureStackTrace(this, PremiumRequiresPaymentError); 10 | } 11 | 12 | // Add a few properties to all the errors defined above 13 | for (let error in module.exports) { 14 | module.exports[error].prototype = Object.create(Error.prototype); 15 | module.exports[error].prototype.constructor = error; 16 | } 17 | -------------------------------------------------------------------------------- /src/helpers/mixpanel.js: -------------------------------------------------------------------------------- 1 | import Mixpanel from 'mixpanel'; 2 | 3 | const MIXPANEL_TOKEN = process.env.MIXPANEL_TOKEN; 4 | let mixpanel = null; 5 | if (MIXPANEL_TOKEN) { 6 | mixpanel = Mixpanel.init(MIXPANEL_TOKEN, {protocol: 'https'}); 7 | } 8 | 9 | export default function analyticsForRoute(req, res, next) { 10 | // If mixpanel token is set, tell mixpanel when a request is made. 11 | if (MIXPANEL_TOKEN && req.user) { 12 | mixpanel.people.set(req.user.id, { 13 | $first_name: req.user.username, 14 | email: req.user.email, 15 | scope: req.user.publicScope ? 'public' : 'private', 16 | }); 17 | 18 | mixpanel.track('Visited Route', { 19 | distinct_id: req.user.id, 20 | url: req.url, 21 | method: req.method, 22 | request: req.headers['x-request-id'], 23 | }); 24 | } 25 | 26 | next(); 27 | } 28 | -------------------------------------------------------------------------------- /src/helpers/route.js: -------------------------------------------------------------------------------- 1 | // Given a route handler: 2 | // - If the handler resolves a response, then send the response. 3 | // - If the handler throws, then catch and send the error. 4 | export default function routeWrapper(handler, dependencies) { 5 | function handleError(res, error) { 6 | if (error.name === 'ValidationError') { 7 | res.status(error.statusCode).send({ 8 | ok: false, 9 | error: 'validation', 10 | context: error.context, 11 | issues: error.codes, 12 | }); 13 | } else { 14 | // Some other error... 15 | res.status(500); 16 | res.send({error: error.message, stack: error.stack}); 17 | }; 18 | 19 | // Rethrow errors so that they'll show up in the log when the server is running in dev of 20 | // production mode. Throwing in test mode spams the console with stack traces from testing 21 | // failure edge cases. 22 | if (process.env.NODE_ENV !== 'test') { throw error; } 23 | } 24 | 25 | return (req, res) => { 26 | try { 27 | return handler(req, res, ...dependencies).then(data => { 28 | // Only send what is resolved is something hasn't been otherwise sent. 29 | if (!res._headerSent) { 30 | res.status(200).send(data); 31 | } 32 | }).catch(error => handleError(res, error)); 33 | } catch (error) { 34 | return handleError(res, error); 35 | } 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/models.js: -------------------------------------------------------------------------------- 1 | import repl from 'repl'; 2 | import debug from 'debug'; 3 | import uuid from 'uuid'; 4 | import fetch from 'node-fetch'; 5 | 6 | import Redis from 'redis'; 7 | const redis = Redis.createClient(process.env.REDIS_URL); 8 | import RedisMQ from 'rsmq'; 9 | const redisQueue = new RedisMQ({ 10 | client: redis, 11 | ns: 'rsmq', 12 | }); 13 | 14 | const ONE_HOUR_IN_SECONDS = 60 * 60; 15 | const LINK_OPERATION_EXPIRY_TIME_IN_SECONDS = 24 * ONE_HOUR_IN_SECONDS; 16 | export const WebhookStatusStore = { 17 | set(webhookId, status, expiresIn=LINK_OPERATION_EXPIRY_TIME_IN_SECONDS) { 18 | return new Promise((resolve, reject) => { 19 | redis.set(`webhook:status:${webhookId}`, JSON.stringify(status), 'EX', expiresIn, (err, id) => { 20 | if (err) { 21 | reject(err); 22 | } else { 23 | // Resolves the message id. 24 | resolve(id); 25 | } 26 | }); 27 | }); 28 | }, 29 | get(webhookId, hideSensitiveKeys=true) { 30 | return new Promise((resolve, reject) => { 31 | redis.get(`webhook:status:${webhookId}`, (err, data) => { 32 | if (err) { 33 | reject(err); 34 | } else { 35 | // Resolves the cached data. 36 | const parsed = JSON.parse(data); 37 | if (hideSensitiveKeys) { 38 | if (parsed) { 39 | // Remove access token from response. 40 | resolve({ 41 | ...parsed, 42 | link: { 43 | ...(parsed.link || {}), 44 | owner: { 45 | ...(parsed.link ? parsed.link.owner : {}), 46 | accessToken: undefined, 47 | }, 48 | }, 49 | }); 50 | } else { 51 | // No operation id was found 52 | return null; 53 | } 54 | } else { 55 | resolve(parsed); 56 | } 57 | } 58 | }); 59 | }); 60 | }, 61 | getOperations(linkId) { 62 | return new Promise((resolve, reject) => { 63 | // Get unix epoch timestamp in seconds. 64 | // FIXME: should use redis time. We're not accounting for any sort of server time drift here. 65 | const timestamp = Math.floor(new Date().getTime() / 1000); 66 | 67 | // Return all operations associated with a given link that have happened in the last 24 hours. 68 | redis.zrangebyscore( 69 | `webhook:operations:${linkId}`, 70 | timestamp - LINK_OPERATION_EXPIRY_TIME_IN_SECONDS, 71 | timestamp, 72 | (err, data) => { 73 | if (err) { 74 | reject(err); 75 | } else { 76 | resolve(data); 77 | } 78 | } 79 | ); 80 | }); 81 | }, 82 | }; 83 | 84 | export const WebhookQueue = { 85 | queueName: process.env.REDIS_QUEUE_NAME || 'webhookQueue', 86 | initialize() { 87 | return new Promise((resolve, reject) => { 88 | redisQueue.createQueue({qname: this.queueName}, (err, resp) => { 89 | if (err && err.name === 'queueExists') { 90 | // Queue was already created. 91 | resolve(); 92 | } else if (err) { 93 | reject(err); 94 | } else { 95 | resolve(resp); 96 | } 97 | }); 98 | }); 99 | }, 100 | push(data) { 101 | return new Promise((resolve, reject) => { 102 | redisQueue.sendMessage({qname: this.queueName, message: JSON.stringify(data)}, (err, id) => { 103 | if (err) { 104 | reject(err); 105 | } else { 106 | // Resolves the message id. 107 | resolve(id); 108 | } 109 | }); 110 | }); 111 | }, 112 | pop() { 113 | return new Promise((resolve, reject) => { 114 | redisQueue.popMessage({qname: this.queueName}, (err, data) => { 115 | if (err) { 116 | reject(err); 117 | } else if (!data || typeof data.id === 'undefined') { 118 | // No items in the queue 119 | resolve(null); 120 | } else { 121 | // Item was found on the end of the queue! 122 | resolve({data: JSON.parse(data.message), id: data.id}); 123 | } 124 | }); 125 | }); 126 | } 127 | }; 128 | WebhookQueue.initialize(); 129 | 130 | 131 | 132 | 133 | import Sequelize from 'sequelize'; 134 | const schema = new Sequelize(process.env.DATABASE_URL, { 135 | dialect: 'postgres', 136 | dialectOptions: { 137 | ssl: process.env.DATABASE_REQUIRE_SSL.toLowerCase() === 'true' ? true : false, 138 | }, 139 | }); 140 | 141 | 142 | 143 | 144 | export const User = schema.define('user', { 145 | id: { 146 | type: Sequelize.INTEGER, 147 | autoIncrement: true, 148 | primaryKey: true, 149 | }, 150 | username: { 151 | type: Sequelize.STRING, 152 | unique: true, 153 | }, 154 | email: { 155 | type: Sequelize.STRING, 156 | allowNull: true, 157 | }, 158 | githubId: { 159 | type: Sequelize.STRING, 160 | allowNull: false, 161 | unique: true, 162 | }, 163 | accessToken: { 164 | type: Sequelize.STRING, 165 | allowNull: false, 166 | }, 167 | 168 | // Did the user register with the `public` scope (only providing access to open source repos)? 169 | publicScope: { type: Sequelize.BOOLEAN }, 170 | 171 | createdAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW}, 172 | lastLoggedInAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW}, 173 | }); 174 | 175 | // Create a new user in the registration function 176 | User.register = async function register(profile, accessToken) { 177 | const logger = debug('backstroke:user:register'); 178 | 179 | // Does the user already exist? 180 | const model = await User.findOne({where: {githubId: profile.id.toString()}}); 181 | 182 | // What permissions was the given token given? 183 | let permissions = []; 184 | const scopes = (await fetch('https://api.github.com/users/backstroke-bot', { 185 | headers: { 186 | 'Authorization': `token ${accessToken}`, 187 | }, 188 | })).headers.get('x-oauth-scopes'); 189 | if (scopes && scopes.length > 0) { 190 | permissions = scopes.split(',').map(i => i.trim()); 191 | } 192 | 193 | // Did the user only give us access to public repos? 194 | const publicScope = permissions.indexOf('public_repo') >= 0; 195 | 196 | // If so, then just update the user model with the new info. 197 | if (model) { 198 | logger( 199 | 'UPDATING USER MODEL %o WITH %o, username = %o, email = %o, publicScope = %o', 200 | model, 201 | profile.id, 202 | profile.username, 203 | profile.email, 204 | publicScope, 205 | ); 206 | 207 | // Update the profile with the new information. 208 | await User.update({ 209 | username: profile.username, 210 | email: profile._json.email, 211 | githubId: profile.id, 212 | accessToken, 213 | publicScope, 214 | 215 | lastLoggedInAt: new Date, 216 | }, {where: {id: model.id}}); 217 | 218 | return User.findById(model.id); 219 | } else { 220 | logger('CREATE USER %o', profile.username); 221 | return User.create({ 222 | username: profile.username, 223 | email: profile._json.email, 224 | githubId: profile.id, 225 | accessToken, 226 | publicScope, 227 | 228 | lastLoggedInAt: new Date, 229 | }); 230 | } 231 | } 232 | 233 | 234 | 235 | 236 | 237 | export const Link = schema.define('link', { 238 | id: { 239 | type: Sequelize.INTEGER, 240 | autoIncrement: true, 241 | primaryKey: true, 242 | }, 243 | name: { 244 | type: Sequelize.STRING, 245 | allowNull: false, 246 | }, 247 | enabled: { 248 | type: Sequelize.BOOLEAN, 249 | allowNull: false, 250 | }, 251 | 252 | webhookId: { type: Sequelize.STRING, defaultValue: () => uuid.v4().replace(/-/g, '') }, 253 | 254 | lastSyncedAt: { type: Sequelize.DATE, defaultValue: Sequelize.NOW}, 255 | 256 | upstreamType: {type: Sequelize.ENUM, values: ['repo']}, 257 | upstreamOwner: Sequelize.STRING, 258 | upstreamRepo: Sequelize.STRING, 259 | upstreamIsFork: Sequelize.BOOLEAN, 260 | upstreamBranches: Sequelize.TEXT, 261 | upstreamBranch: Sequelize.STRING, 262 | // Store the last known SHA for the commit at the HEAD of the `upstreamBranch` branch. 263 | upstreamLastSHA: Sequelize.STRING, 264 | 265 | forkType: {type: Sequelize.ENUM, values: ['repo', 'fork-all']}, 266 | forkOwner: Sequelize.STRING, 267 | forkRepo: Sequelize.STRING, 268 | forkBranches: Sequelize.TEXT, 269 | forkBranch: Sequelize.STRING, 270 | }); 271 | 272 | // A link has a foreign key to a user. 273 | Link.belongsTo(User, {as: 'owner', foreignKey: 'ownerId'}); 274 | 275 | // Convert a link to its owtward-facing structure. Expand all foreign keys and 276 | // remove sensitive data. 277 | Link.prototype.display = function display() { 278 | return { 279 | id: this.id, 280 | name: this.name, 281 | enabled: this.enabled, 282 | webhook: this.webhookId, 283 | 284 | createdAt: this.createdAt, 285 | lastSyncedAt: this.lastSyncedAt, 286 | 287 | fork: this.fork(), 288 | upstream: this.upstream(), 289 | }; 290 | } 291 | 292 | Link.prototype.fork = function fork() { 293 | if (this.forkType === 'fork-all') { 294 | return {type: 'fork-all'}; 295 | } else { 296 | return { 297 | type: this.forkType, 298 | owner: this.forkOwner, 299 | repo: this.forkRepo, 300 | isFork: true, 301 | branches: this.forkBranches ? JSON.parse(this.forkBranches) : [], 302 | branch: this.forkBranch, 303 | }; 304 | } 305 | } 306 | 307 | Link.prototype.upstream = function upstream() { 308 | return { 309 | type: this.upstreamType, 310 | owner: this.upstreamOwner, 311 | repo: this.upstreamRepo, 312 | isFork: this.upstreamFork, 313 | branches: this.upstreamBranches ? JSON.parse(this.upstreamBranches) : [], 314 | branch: this.upstreamBranch, 315 | }; 316 | } 317 | 318 | if (require.main === module) { 319 | if (process.argv[2] === 'migrate') { 320 | console.log('Migrating schema...'); 321 | Link.sync({alter: true}); 322 | User.sync({alter: true}); 323 | console.log('Done.'); 324 | } else if (process.argv[2] === 'shell') { 325 | const options = { 326 | useColors: true, 327 | useGlobal: true, 328 | }; 329 | const context = { 330 | redis, 331 | schema, 332 | Link, 333 | User, 334 | WebhookQueue, 335 | WebhookStatusStore, 336 | }; 337 | 338 | // From https://stackoverflow.com/questions/33673999/passing-context-to-interactive-node-shell-leads-to-typeerror-sandbox-argument 339 | Object.assign(repl.start(options).context, context); 340 | } 341 | } 342 | -------------------------------------------------------------------------------- /src/root-file.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Backstroke Server 4 | 35 | 36 | 37 | 38 | 39 | 40 | Created with Sketch. 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 |

This is the Backstroke API.

382 |

Visit the dashboard to manage your links.

383 | 384 | 385 | -------------------------------------------------------------------------------- /src/routes/checkRepo.js: -------------------------------------------------------------------------------- 1 | import {paginateRequest} from './helpers'; 2 | 3 | export default function checkRepo(req, res) { 4 | // Get repo details, and associated branches 5 | return req.github.user.reposGet({ 6 | owner: req.params.user, 7 | repo: req.params.repo, 8 | }).then(repoData => { 9 | return paginateRequest(req.github.user.reposGetBranches, { 10 | owner: req.params.user, 11 | repo: req.params.repo, 12 | per_page: 100, 13 | }).then(branches => { 14 | // format as a response 15 | res.status(200).send({ 16 | valid: true, 17 | private: repoData.private, 18 | fork: repoData.fork, 19 | parent: repoData.parent ? { 20 | owner: repoData.parent.owner.login, 21 | name: repoData.parent.name, 22 | private: repoData.parent.private, 23 | defaultBranch: repoData.parent.default_branch, 24 | } : null, 25 | branches: branches.map(b => b.name), 26 | }); 27 | }).catch(err => { 28 | throw err; 29 | }); 30 | }).catch(err => { 31 | // repo doesn't exist. 32 | res.status(404).send({valid: false}); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/helpers.js: -------------------------------------------------------------------------------- 1 | // A bunch of helpers that are used in controllers. 2 | // 3 | // Default page size. 4 | export const PAGE_SIZE = 20; 5 | 6 | // A helper to paginate queries. 7 | // Use like: 8 | // Model.all({...paginate(req)}).then(data => ...); 9 | export function paginate(req) { 10 | let page = parseInt(req.query.page, 10) || 0; 11 | return {offset: page * PAGE_SIZE, limit: PAGE_SIZE}; 12 | } 13 | 14 | // Something bad happened. Throw a 500. 15 | export function internalServerErrorOnError(res) { 16 | return error => { 17 | if (err.name === 'ValidationError') { 18 | res.status(err.statusCode).send({ 19 | ok: false, 20 | error: 'validation', 21 | context: err.context, 22 | issues: err.codes, 23 | }); 24 | } else { 25 | console.error(err.stack); 26 | res.status(500).send(err.stack); 27 | }; 28 | }; 29 | } 30 | 31 | // Given a method and arguments, issue a request until all possible data items come through. 32 | export function paginateRequest(method, args, pageSize=100, page=0, cumulativeData=[]) { 33 | // Add a page size to the request. 34 | if (!Array.isArray(args)) { 35 | args = [args]; 36 | } 37 | args[0].page = page; 38 | args[0].per_page = pageSize; 39 | 40 | return method.apply(null, args).then(data => { 41 | if (data.length === pageSize) { 42 | // Data is still coming, go for another round. 43 | cumulativeData = [...cumulativeData, ...data]; 44 | return paginateRequest(method, args, pageSize, ++page, cumulativeData); 45 | } else if (data.length < pageSize) { 46 | // Fewer resuts returned than expected, so we know this is the last page. 47 | cumulativeData = [...cumulativeData, ...data]; 48 | return cumulativeData; 49 | } else { 50 | // NOTE: this case should never happen, where more results are returned then expected. 51 | return cumulativeData; 52 | } 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/routes/links/create/index.js: -------------------------------------------------------------------------------- 1 | // Create a new Link. This new link is disabled and is really just a 2 | // placeholder for an update later on. 3 | export default function create(req, res, Link) { 4 | return Link.create({ 5 | name: '', 6 | enabled: false, 7 | ownerId: req.user.id, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/routes/links/create/test.js: -------------------------------------------------------------------------------- 1 | import create from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Repository = new MockModel(), 13 | Link = new MockModel([], {upstream: Repository, owner: User, fork: Repository}); 14 | 15 | Link.methods.display = function() { return this; } 16 | 17 | describe('link create', () => { 18 | let user, link; 19 | 20 | beforeEach(async function() { 21 | user = await User.create({username: 'ryan'}); 22 | link = await Link.create({ 23 | name: 'My Link', 24 | enabled: true, 25 | owner: user.id, 26 | 27 | upstreamType: 'repo', 28 | upstreamOwner: 'foo', 29 | upstreamRepo: 'bar', 30 | upstreamIsFork: false, 31 | upstreamBranches: '["master"]', 32 | upstreamBranch: 'master', 33 | 34 | forkType: 'all-forks', 35 | forkOwner: undefined, 36 | forkRepo: undefined, 37 | forkBranches: undefined, 38 | forkBranch: undefined, 39 | }); 40 | }); 41 | 42 | it('should create a link for a user', () => { 43 | return issueRequest( 44 | create, [Link], 45 | '/', user, { 46 | method: 'POST', 47 | url: '/', 48 | json: true, 49 | } 50 | ).then(res => { 51 | assert.notEqual(res.body.id, link.id); // Make sure the id is something else. 52 | return Link.findById(res.body.id); 53 | }).then(link => { 54 | assert.equal(link.name, ''); 55 | assert.equal(link.enabled, false); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/routes/links/delete/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:links:delete'); 3 | 4 | // Delete a link. Returns a 204 on success, or a 404 / 500 on error. 5 | export default async function del(req, res, Link) { 6 | const numRemoved = await Link.destroy({ 7 | where: { 8 | id: req.params.id, 9 | ownerId: req.user.id, 10 | }, 11 | limit: 1, 12 | }); 13 | 14 | if (numRemoved > 0) { 15 | res.status(204).end(); 16 | return null; 17 | } else { 18 | res.status(404).send({ 19 | error: 'No such link found that is owned by this account.', 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/links/delete/test.js: -------------------------------------------------------------------------------- 1 | import del from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Link = new MockModel([], {owner: User}); 13 | 14 | Link.methods.display = function() { return this; } 15 | 16 | describe('link delete', () => { 17 | let user, link; 18 | 19 | beforeEach(async function() { 20 | user = await User.create({username: 'ryan'}); 21 | link = await Link.create({ 22 | name: 'My Link', 23 | enabled: true, 24 | owner: user.id, 25 | 26 | upstreamType: 'repo', 27 | upstreamOwner: 'foo', 28 | upstreamRepo: 'bar', 29 | upstreamIsFork: false, 30 | upstreamBranches: '["master"]', 31 | upstreamBranch: 'master', 32 | 33 | forkType: 'all-forks', 34 | forkOwner: undefined, 35 | forkRepo: undefined, 36 | forkBranches: undefined, 37 | forkBranch: undefined, 38 | }); 39 | }); 40 | 41 | it('should delete a link for a user', () => { 42 | return issueRequest( 43 | del, [Link], 44 | '/:id', user, { 45 | method: 'DELETE', 46 | url: `/${link.id}`, 47 | json: true, 48 | } 49 | ).then(res => { 50 | assert.equal(res.statusCode, 204); 51 | return Link.findOne({where: {id: link.id}}); 52 | }).then(link => { 53 | assert.equal(link, null); // Link no longer exists. 54 | }); 55 | }); 56 | it('should try to delete a link, but fail when the link id is invalid', () => { 57 | return issueRequest( 58 | del, [Link], 59 | '/:id', user, { 60 | method: 'DELETE', 61 | url: `/21t2413131314913491`, // Bogus link id 62 | json: true, 63 | } 64 | ).then(res => { 65 | assert.equal(res.body.error, 'No such link found that is owned by this account.'); 66 | }); 67 | }); 68 | it(`should try to delete a link, but fail when the user trying to delete doesn't own the link`, () => { 69 | return issueRequest( 70 | del, [Link], 71 | '/:id', {id: 'bogus'} /* some bogus user */, { 72 | method: 'DELETE', 73 | url: `/${link.id}`, // Bogus link id 74 | json: true, 75 | } 76 | ).then(res => { 77 | assert.equal(res.body.error, 'No such link found that is owned by this account.'); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/routes/links/enable/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:links:get'); 3 | 4 | // Enable or disable a link. Requires a body like {"enabled": true/false}, and 5 | // responds with {"status": "ok"} 6 | export default async function enable(req, res, Link) { 7 | if (typeof req.body.enabled !== 'boolean') { 8 | throw new Error('Enabled property not specified in the body.'); 9 | } 10 | 11 | const link = await Link.findById(req.params.linkId) 12 | 13 | // Link not owned by user. 14 | if (link && link.ownerId !== req.user.id) { 15 | debug('Link %o not owned by %o', link.id, req.user.id); 16 | throw new Error('No such link.'); 17 | 18 | // Link not valid. 19 | } else if (link && (!link.upstreamType || !link.forkType)) { 20 | throw new Error('Please update the link with a valid upstream and fork before enabling.'); 21 | 22 | // Link is valid! 23 | } else if (link) { 24 | await Link.update({enabled: req.body.enabled}, {where: {id: link.id}}); 25 | return {status: 'ok'}; 26 | 27 | // Link does not exist. 28 | } else { 29 | throw new Error('No such link.'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/links/enable/test.js: -------------------------------------------------------------------------------- 1 | import enable from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Link = new MockModel([], {owner: User}); 13 | 14 | Link.methods.display = function() { return this; } 15 | 16 | describe('link enable', () => { 17 | let user, user2, link, link2, invalidLink; 18 | 19 | beforeEach(async function() { 20 | user = await User.create({username: 'ryan'}); 21 | user2 = await User.create({username: 'bill'}); 22 | 23 | link = await Link.create({ 24 | name: 'My Link', 25 | enabled: true, 26 | owner: user.id, 27 | 28 | upstreamType: 'repo', 29 | upstreamOwner: 'foo', 30 | upstreamRepo: 'bar', 31 | upstreamIsFork: false, 32 | upstreamBranches: '["master"]', 33 | upstreamBranch: 'master', 34 | 35 | forkType: 'all-forks', 36 | forkOwner: undefined, 37 | forkRepo: undefined, 38 | forkBranches: undefined, 39 | forkBranch: undefined, 40 | }); 41 | link2 = await Link.create({ 42 | name: 'My non-owned Link', 43 | enabled: true, 44 | owner: user2.id, 45 | 46 | upstreamType: 'repo', 47 | upstreamOwner: 'foo', 48 | upstreamRepo: 'bar', 49 | upstreamIsFork: false, 50 | upstreamBranches: '["master"]', 51 | upstreamBranch: 'master', 52 | 53 | forkType: 'all-forks', 54 | forkOwner: undefined, 55 | forkRepo: undefined, 56 | forkBranches: undefined, 57 | forkBranch: undefined, 58 | }); 59 | invalidLink = await Link.create({ 60 | name: '', 61 | enabled: true, 62 | owner: user.id, 63 | 64 | upstreamType: undefined, 65 | forkType: undefined, 66 | }); 67 | }); 68 | 69 | it('should enable a link for a user', () => { 70 | const enabledState = !link.enabled; 71 | return issueRequest( 72 | enable, [Link], 73 | '/:linkId', user, { 74 | method: 'PUT', 75 | url: `/${link.id}`, 76 | json: true, 77 | body: { 78 | enabled: enabledState, 79 | }, 80 | } 81 | ).then(res => { 82 | assert.equal(res.statusCode, 200); 83 | return Link.findOne({where: {id: link.id}}); 84 | }).then(link => { 85 | assert.equal(link.enabled, enabledState); 86 | }); 87 | }); 88 | it('should try to enable a link, but fail with a malformed body', () => { 89 | const enabledState = !link.enabled; 90 | return issueRequest( 91 | enable, [Link], 92 | '/:linkId', user, { 93 | method: 'PUT', 94 | url: `/${link.id}`, 95 | json: true, 96 | body: { 97 | no: 'enabled', 98 | property: 'here', 99 | }, 100 | } 101 | ).then(res => { 102 | assert.equal(res.body.error, `Enabled property not specified in the body.`); 103 | }); 104 | }); 105 | it('should try to enable a link, but should fail with a bad link id', () => { 106 | const enabledState = !link.enabled; 107 | return issueRequest( 108 | enable, [Link], 109 | '/:linkId', user, { 110 | method: 'PUT', 111 | url: `/32542542y52451311341`, // Bogus link id 112 | json: true, 113 | body: { 114 | enabled: enabledState, 115 | }, 116 | } 117 | ).then(res => { 118 | assert.equal(res.body.error, 'No such link.'); 119 | }); 120 | }); 121 | it.only(`should try to enable a link, but should fail if a link is not valid (ie, fork type and upstream type are empty)`, () => { 122 | const enabledState = !link.enabled; 123 | return issueRequest( 124 | enable, [Link], 125 | '/:linkId', user, { 126 | method: 'PUT', 127 | url: `/${invalidLink.id}`, // Bogus link id 128 | json: true, 129 | body: { 130 | enabled: enabledState, 131 | }, 132 | } 133 | ).then(res => { 134 | assert.equal(res.body.error, 'Please update the link with a valid upstream and fork before enabling.'); 135 | }); 136 | }); 137 | it(`should try to enable a link, but should fail when the link isn't owned by the current user.`, () => { 138 | const enabledState = !link2.enabled; 139 | return issueRequest( 140 | enable, [Link], 141 | '/:linkId', user, { 142 | method: 'PUT', 143 | url: `/${link2.id}`, // Bogus link id 144 | json: true, 145 | body: { 146 | enabled: enabledState, 147 | }, 148 | } 149 | ).then(res => { 150 | assert.equal(res.body.error, 'No such link.'); 151 | }); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/routes/links/get-operations/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:links:getOperations'); 3 | 4 | // Return all link operations that are associated with a given link. 5 | export default async function getOperations(req, res, Link, WebhookStatusStore) { 6 | const link = await Link.findById(req.params.id); 7 | 8 | if (link && link.ownerId !== req.user.id) { 9 | debug('LINK %o NOT OWNED BY %o', link.id, req.user.id); 10 | throw new Error('No such link.'); 11 | } else if (link) { 12 | // Fetch all operations associated with a link. 13 | const operations = await WebhookStatusStore.getOperations(link.id); 14 | 15 | // Allow passing an optional ?detail=true query param to lookup each operation. 16 | if (req.query.detail && req.query.detail.toLowerCase() === 'true') { 17 | // Lookup each operation in parallel, and return them. 18 | const statuses = await Promise.all(operations.map(op => WebhookStatusStore.get(op))); 19 | // Add an id back to each response 20 | return statuses.map((status, index) => Object.assign({id: operations[index]}, status)); 21 | } 22 | 23 | // Otherwise, return just hte operation ids. 24 | return operations; 25 | } else { 26 | throw new Error('No such link.'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/routes/links/get-operations/test.js: -------------------------------------------------------------------------------- 1 | import get from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Link = new MockModel([], {owner: User}); 13 | 14 | Link.methods.display = function() { return this; } 15 | 16 | describe('link get operations', () => { 17 | let user, link, link2; 18 | 19 | beforeEach(async function() { 20 | user = await User.create({username: 'ryan'}); 21 | link = await Link.create({ 22 | name: 'My Link', 23 | enabled: true, 24 | owner: user.id, 25 | 26 | upstreamType: 'repo', 27 | upstreamOwner: 'foo', 28 | upstreamRepo: 'bar', 29 | upstreamIsFork: false, 30 | upstreamBranches: '["master"]', 31 | upstreamBranch: 'master', 32 | 33 | forkType: 'all-forks', 34 | forkOwner: undefined, 35 | forkRepo: undefined, 36 | forkBranches: undefined, 37 | forkBranch: undefined, 38 | }); 39 | }); 40 | 41 | it('should fetch all operations for a link in the past 24 hours', () => { 42 | // Grab the most recent model from the mock (.models is a mock-specific property) 43 | const linkModel = Link.models[Link.models.length - 1]; 44 | 45 | // Create a mock status store to use with this test. 46 | const MockWebhookStatusStore = { 47 | getOperations: sinon.stub().resolves(['adgrha', 'uyrjnh', 'brsnyi']), 48 | get: sinon.stub().resolves({status: 'ok'}), 49 | }; 50 | 51 | return issueRequest( 52 | get, [Link, MockWebhookStatusStore], 53 | '/operations/:id', user, { 54 | method: 'GET', 55 | url: `/operations/${linkModel.id}`, 56 | json: true, 57 | } 58 | ).then(res => { 59 | const body = res.body; 60 | assert.deepEqual(body, ['adgrha', 'uyrjnh', 'brsnyi']); 61 | 62 | // Assert that `.get` wasn't called. 63 | assert.equal(MockWebhookStatusStore.getOperations.callCount, 1); 64 | assert.equal(MockWebhookStatusStore.get.callCount, 0); 65 | }); 66 | }); 67 | it('should try to fetch all operations for a link, but fail if the link id is bad', () => { 68 | // Create a mock status store to use with this test. 69 | const MockWebhookStatusStore = { 70 | getOperations: sinon.stub().resolves(['adgrha', 'uyrjnh', 'brsnyi']), 71 | get: sinon.stub().resolves({status: 'ok'}), 72 | }; 73 | 74 | return issueRequest( 75 | get, [Link, MockWebhookStatusStore], 76 | '/:id', user, { 77 | method: 'GET', 78 | url: `/13527501385710357139f313`, // Bogus id 79 | json: true, 80 | } 81 | ).then(res => { 82 | const body = res.body; 83 | assert.equal(body.error, 'No such link.'); 84 | 85 | // Assert that both functions weren't called. 86 | assert.equal(MockWebhookStatusStore.getOperations.callCount, 0); 87 | assert.equal(MockWebhookStatusStore.get.callCount, 0); 88 | }); 89 | }); 90 | it('should try to fetch all operations for a link, but fail when the redis call fails', () => { 91 | // Grab the most recent model from the mock (.models is a mock-specific property) 92 | const linkModel = Link.models[Link.models.length - 1]; 93 | 94 | // Create a mock status store to use with this test. 95 | const MockWebhookStatusStore = { 96 | getOperations: sinon.stub().rejects(new Error('Boom!')), 97 | get: sinon.stub().resolves({status: 'ok'}), 98 | }; 99 | 100 | return issueRequest( 101 | get, [Link, MockWebhookStatusStore], 102 | '/operations/:id', user, { 103 | method: 'GET', 104 | url: `/operations/${linkModel.id}`, 105 | json: true, 106 | } 107 | ).then(res => { 108 | const body = res.body; 109 | assert.equal(body.error, 'Boom!'); 110 | 111 | // Assert that `.get` wasn't called. 112 | assert.equal(MockWebhookStatusStore.getOperations.callCount, 1); 113 | assert.equal(MockWebhookStatusStore.get.callCount, 0); 114 | }); 115 | }); 116 | it('should fetch all operations for a link in the past 24 hours, with ?detail=true param', () => { 117 | // Grab the most recent model from the mock (.models is a mock-specific property) 118 | const linkModel = Link.models[Link.models.length - 1]; 119 | 120 | // Create a mock status store to use with this test. 121 | const MockWebhookStatusStore = { 122 | getOperations: sinon.stub().resolves(['adgrha', 'uyrjnh', 'brsnyi']), 123 | get: sinon.stub().resolves({status: 'OK'}), 124 | }; 125 | 126 | return issueRequest( 127 | get, [Link, MockWebhookStatusStore], 128 | '/operations/:id', user, { 129 | method: 'GET', 130 | url: `/operations/${linkModel.id}?detail=true`, 131 | json: true, 132 | } 133 | ).then(res => { 134 | const body = res.body; 135 | assert.deepEqual(body, [ 136 | {id: 'adgrha', status: 'OK'}, 137 | {id: 'uyrjnh', status: 'OK'}, 138 | {id: 'brsnyi', status: 'OK'}, 139 | ]); 140 | 141 | // Assert that `.getOperations` was called once and `.get` was called three times. 142 | assert.equal(MockWebhookStatusStore.getOperations.callCount, 1); 143 | assert.equal(MockWebhookStatusStore.get.callCount, 3); 144 | }); 145 | }); 146 | it('should try to fetch all operations for a link with ?detail=true param, but fail when the redis call fails', () => { 147 | // Grab the most recent model from the mock (.models is a mock-specific property) 148 | const linkModel = Link.models[Link.models.length - 1]; 149 | 150 | // Create a mock status store to use with this test. 151 | const MockWebhookStatusStore = { 152 | getOperations: sinon.stub().resolves(['adgrha', 'uyrjnh', 'brsnyi']), 153 | get: sinon.stub().rejects(new Error('Boom!')), 154 | }; 155 | 156 | return issueRequest( 157 | get, [Link, MockWebhookStatusStore], 158 | '/operations/:id', user, { 159 | method: 'GET', 160 | url: `/operations/${linkModel.id}?detail=TRUE`, 161 | json: true, 162 | } 163 | ).then(res => { 164 | const body = res.body; 165 | assert.equal(body.error, 'Boom!'); 166 | 167 | assert.equal(MockWebhookStatusStore.getOperations.callCount, 1); 168 | assert.equal(MockWebhookStatusStore.get.callCount, 3); 169 | }); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/routes/links/get/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:links:get'); 3 | 4 | // Return one single link in full, expanded format. 5 | export default function get(req, res, Link) { 6 | return Link.findById(req.params.id).then(link => { 7 | if (link && link.ownerId !== req.user.id) { 8 | debug('LINK %o NOT OWNED BY %o', link.id, req.user.id); 9 | throw new Error('No such link.'); 10 | } else if (link) { 11 | return link.display(); 12 | } else { 13 | throw new Error('No such link.'); 14 | } 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/routes/links/get/test.js: -------------------------------------------------------------------------------- 1 | import get from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Link = new MockModel([], {owner: User}); 13 | 14 | Link.methods.display = function() { return this; } 15 | 16 | describe('link get', () => { 17 | let user, link, link2; 18 | 19 | beforeEach(async function() { 20 | user = await User.create({username: 'ryan'}); 21 | link = await Link.create({ 22 | name: 'My Link', 23 | enabled: true, 24 | owner: user.id, 25 | 26 | upstreamType: 'repo', 27 | upstreamOwner: 'foo', 28 | upstreamRepo: 'bar', 29 | upstreamIsFork: false, 30 | upstreamBranches: '["master"]', 31 | upstreamBranch: 'master', 32 | 33 | forkType: 'all-forks', 34 | forkOwner: undefined, 35 | forkRepo: undefined, 36 | forkBranches: undefined, 37 | forkBranch: undefined, 38 | }); 39 | }); 40 | 41 | it('should get a link for a user', () => { 42 | // Grab the first model from the mock (.models is a mock-specific property) 43 | const linkModel = Link.models[0]; 44 | 45 | return issueRequest( 46 | get, [Link], 47 | '/:id', user, { 48 | method: 'GET', 49 | url: `/${linkModel.id}`, 50 | json: true, 51 | } 52 | ).then(res => { 53 | const body = res.body; 54 | assert.equal(body.id, linkModel.id); 55 | }); 56 | }); 57 | it('should try to get a link but fail', () => { 58 | // Grab the first model from the mock (.models is a mock-specific property) 59 | const linkModel = Link.models[0]; 60 | 61 | return issueRequest( 62 | get, [Link], 63 | '/:id', user, { 64 | method: 'GET', 65 | url: `/13527501385710357139f313`, // Bogus id 66 | json: true, 67 | } 68 | ).then(res => { 69 | const body = res.body; 70 | assert.equal(body.error, 'No such link.'); 71 | }); 72 | }); 73 | it('should try to get a link but the link is not owned by the authed user', () => { 74 | // Grab the first model from the mock (.models is a mock-specific property) 75 | const linkModel = Link.models[0]; 76 | 77 | return issueRequest( 78 | get, [Link], 79 | '/:id', null, { 80 | method: 'GET', 81 | url: `/13527501385710357139f313`, // Bogus id 82 | json: true, 83 | } 84 | ).then(res => { 85 | const body = res.body; 86 | assert.equal(body.error, 'No such link.'); 87 | }); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /src/routes/links/list/index.js: -------------------------------------------------------------------------------- 1 | import {PAGE_SIZE, paginate} from '../../helpers'; 2 | 3 | // Return a list of all links that belong to the logged in user. 4 | // This route is paginated. 5 | export default function index(req, res, Link) { 6 | return Link.findAll({ 7 | where: {ownerId: req.user.id}, 8 | ...paginate(req), 9 | }).then(data => { 10 | // Add all owners to each link 11 | return Promise.all(data.map(i => i.display())).then(data => { 12 | return { 13 | page: parseInt(req.query.page, 10) || 0, 14 | pageSize: PAGE_SIZE, 15 | data, 16 | }; 17 | }); 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/routes/links/list/test.js: -------------------------------------------------------------------------------- 1 | import list from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const User = new MockModel(), 12 | Link = new MockModel([], {owner: User}); 13 | 14 | Link.methods.display = function() { return this; } 15 | 16 | describe('link list', () => { 17 | let userData, linkData; 18 | 19 | beforeEach(function() { 20 | return Promise.all([ 21 | User.create({username: 'ryan'}), 22 | ]).then(([user]) => { 23 | userData = user; 24 | return Link.create({ 25 | name: 'My Link', 26 | enabled: true, 27 | owner: userData.id, 28 | }); 29 | }).then(link => { 30 | linkData = link; 31 | }); 32 | }); 33 | 34 | it('should return all links for a user', () => { 35 | return issueRequest( 36 | list, [Link], 37 | '/', userData, { 38 | method: 'GET', 39 | url: '/', 40 | json: true, 41 | } 42 | ).then(res => { 43 | const body = res.body; 44 | assert.equal(body.data.length, 1); 45 | assert.equal(body.data[0].id, linkData.id); 46 | assert.equal(body.page, 0); 47 | }); 48 | }); 49 | 50 | describe('paging', () => { 51 | beforeEach(() => { 52 | // Add 25 links to the model. 53 | Link.models = []; 54 | for (let i = 0; i < 25; i++) { 55 | Link.models.push({id: i, name: `My link: ${Math.random()}`, ownerId: userData.id}); 56 | } 57 | }); 58 | 59 | it('should return the first, full page of all links for a user', () => { 60 | return issueRequest( 61 | list, [Link], 62 | '/', userData, { 63 | method: 'GET', 64 | url: '/', 65 | json: true, 66 | qs: { page: '0' }, 67 | } 68 | ).then(res => { 69 | const body = res.body; 70 | assert.equal(body.data.length, 20); 71 | assert.equal(body.page, 0); 72 | }); 73 | }); 74 | it('should return the second, partial page of all links for a user', () => { 75 | return issueRequest( 76 | list, [Link], 77 | '/', userData, { 78 | method: 'GET', 79 | url: '/', 80 | json: true, 81 | qs: { page: '1' }, 82 | } 83 | ).then(res => { 84 | const body = res.body; 85 | assert.equal(body.data.length, 5); 86 | assert.equal(body.page, 1); 87 | }); 88 | }); 89 | it('should return the third, empty page of all links for a user', () => { 90 | return issueRequest( 91 | list, [Link], 92 | '/', userData, { 93 | method: 'GET', 94 | url: '/', 95 | json: true, 96 | qs: { page: '2' }, 97 | } 98 | ).then(res => { 99 | const body = res.body; 100 | assert.equal(body.data.length, 0); 101 | assert.equal(body.page, 2); 102 | }); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/routes/links/update/helpers.js: -------------------------------------------------------------------------------- 1 | import GitHubApi from 'github'; 2 | 3 | // Is the given user a collaborator or the given repository? 4 | export async function isCollaboratorOfRepository(user, {owner, repo}) { 5 | const github = new GitHubApi({}); 6 | github.authenticate({ type: "oauth", token: user.accessToken }); 7 | 8 | try { 9 | await github.repos.checkCollaborator({ 10 | owner, repo, 11 | username: user.username, 12 | }); 13 | return true; 14 | } catch (err) { 15 | return false; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/routes/links/update/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:links:update'); 3 | 4 | // Update a Link. This method requires a body with a link property. 5 | // Responds with {"status": "ok"} on success. 6 | export default async function update(req, res, Link, isCollaboratorOfRepository) { 7 | // Ensure req.body is an object (ie, {}) 8 | if (Object.prototype.toString.call(req.body) !== '[object Object]') { 9 | res.status(400).send({error: 'Invalid json body.'}); 10 | } 11 | 12 | if (!req.body.upstream) { 13 | res.status(400).send({error: `Link doesn't have an 'upstream' key.`}); 14 | } 15 | if (!req.body.fork) { 16 | res.status(400).send({error: `Link doesn't have a 'fork' key.`}); 17 | } 18 | 19 | if (req.body.upstream && req.body.upstream.type === 'fork-all') { 20 | res.status(400).send({error: `The 'upstream' repo must be a repo, not a bunch of forks.`}); 21 | } 22 | 23 | if (!Array.isArray(req.body.upstream.branches)) { 24 | res.status(400).send({error: `The upstream wasn't passed an array of branches.`}); 25 | } 26 | 27 | if (req.body.upstream.type === 'repo' && !Array.isArray(req.body.upstream.branches)) { 28 | res.status(400).send({error: `An array of branches wasn't passed to the upstream.`}); 29 | } 30 | 31 | if (req.body.fork.type === 'repo' && !Array.isArray(req.body.fork.branches)) { 32 | res.status(400).send({error: `An array of branches wasn't passed to the fork.`}); 33 | } else if (req.body.fork.type === 'fork-all') { 34 | // A link of type fork-all doesn't have any branches. 35 | req.body.fork.branches = []; 36 | } 37 | 38 | // Make sure that the user has permission to create this link. 39 | // 1. If the fork.type == 'repo', then make sure the user has permission to write to the fork repository. 40 | // 2. If the fork.type == 'fork-all', then make sure the user has permission to the upstream. 41 | if (req.body.fork.type === 'fork-all') { 42 | const isCollaborator = await isCollaboratorOfRepository(req.user, req.body.upstream); 43 | if (!isCollaborator) { 44 | debug('WITHIN LINK %o, CHECKING ON UPSTREAM, USER IS NOT COLLABORATOR', req.params.linkId); 45 | res.status(400).send({error: `To update a link that syncs changes from the upstream ${req.body.upstream.owner}/${req.body.upstream.repo} to all fork, you need to be a collaborator on ${req.body.upstream.owner}/${req.body.upstream.repo}. Instead, sync to a single fork that you own instead of all forks.`}); 46 | return 47 | } 48 | } else { 49 | const isCollaborator = await isCollaboratorOfRepository(req.user, req.body.fork); 50 | if (!isCollaborator) { 51 | debug('WITHIN LINK %o, CHECKING ON FORK, USER IS NOT COLLABORATOR', req.params.linkId); 52 | res.status(400).send({error: `You need to be a collaborator of ${req.body.fork.owner}/${req.body.fork.repo} to sync changes to that fork.`}); 53 | return 54 | } 55 | } 56 | debug('USER HAS PERMISSION TO CREATE/UPDATE LINK %o', req.params.linkId); 57 | 58 | // Execute the update. 59 | const response = await Link.update({ 60 | name: req.body.name, 61 | enabled: req.body.enabled, 62 | 63 | upstreamType: req.body.upstream.type, 64 | upstreamOwner: req.body.upstream.owner, 65 | upstreamRepo: req.body.upstream.repo, 66 | upstreamIsFork: req.body.upstream.isFork, 67 | upstreamBranches: JSON.stringify(req.body.upstream.branches), 68 | upstreamBranch: req.body.upstream.branch, 69 | 70 | forkType: req.body.fork.type, 71 | forkOwner: req.body.fork.owner, 72 | forkRepo: req.body.fork.repo, 73 | forkBranches: JSON.stringify(req.body.fork.branches), 74 | forkBranch: req.body.fork.branch, 75 | }, { 76 | where: { 77 | id: req.params.linkId, 78 | ownerId: req.user.id, 79 | }, 80 | limit: 1, 81 | }); 82 | 83 | // Verify the update was successful. 84 | if (response[0] > 0) { 85 | debug('UPDATED LINK ID %o', req.params.linkId); 86 | const updated = await Link.findById(req.params.linkId); 87 | return updated.display(); 88 | } else { 89 | res.status(404).send({error: 'No such link found that is owned by this account.'}); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/routes/links/update/test.js: -------------------------------------------------------------------------------- 1 | import update from './'; 2 | 3 | import sinon from 'sinon'; 4 | import assert from 'assert'; 5 | 6 | // Helper for mounting routes in an express app and querying them. 7 | // import db from '../../test-helpers/create-database-model-instances'; 8 | import issueRequest from '../../../test-helpers/issue-request'; 9 | import MockModel from '../../../test-helpers/mock-model'; 10 | 11 | const isCollaboratorOfRepository = () => Promise.resolve(true); 12 | 13 | const User = new MockModel(), 14 | Link = new MockModel([], {owner: User}); 15 | 16 | Link.methods.display = function() { return this; } 17 | 18 | describe('link update', () => { 19 | let user, link; 20 | 21 | beforeEach(async function() { 22 | user = await User.create({username: 'ryan'}); 23 | link = await Link.create({ 24 | name: 'My Link', 25 | enabled: true, 26 | owner: user.id, 27 | 28 | upstreamType: 'repo', 29 | upstreamOwner: 'foo', 30 | upstreamRepo: 'bar', 31 | upstreamIsFork: false, 32 | upstreamBranches: '["master"]', 33 | upstreamBranch: 'master', 34 | 35 | forkType: 'fork-all', 36 | forkOwner: undefined, 37 | forkRepo: undefined, 38 | forkBranches: undefined, 39 | forkBranch: undefined, 40 | }); 41 | }); 42 | 43 | it('should update a link for a user', () => { 44 | return issueRequest( 45 | update, [Link, isCollaboratorOfRepository], 46 | '/:linkId', user, { 47 | method: 'PUT', 48 | url: `/${link.id}`, 49 | json: true, 50 | body: { 51 | name: 'Another name for my link!', 52 | upstream: { 53 | type: 'repo', 54 | owner: 'foo', 55 | repo: 'bar', 56 | isFork: false, 57 | branches: ['master'], 58 | branch: 'master', 59 | }, 60 | fork: { 61 | type: 'fork-all' 62 | }, 63 | }, 64 | }, 65 | ).then(res => { 66 | const body = res.body; 67 | assert.equal(body.name, 'Another name for my link!'); 68 | 69 | return Link.findById(link.id); 70 | }).then(link => { 71 | assert.equal(link.name, 'Another name for my link!'); 72 | }); 73 | }); 74 | it(`should update a link for a user but the user doesn't own the link`, () => { 75 | return issueRequest( 76 | update, [Link, isCollaboratorOfRepository], 77 | '/:linkId', {id: 'bogus'} /* bogus user */, { 78 | method: 'PUT', 79 | url: `/${link.id}`, 80 | json: true, 81 | body: { 82 | name: 'Another name for my link!', 83 | upstream: { 84 | type: 'repo', 85 | owner: 'foo', 86 | repo: 'bar', 87 | isFork: false, 88 | branches: ['master'], 89 | branch: 'master', 90 | }, 91 | fork: { 92 | type: 'fork-all', 93 | }, 94 | }, 95 | }, 96 | ).then(res => { 97 | assert.equal(res.statusCode, 404); 98 | assert.equal(res.body.error, 'No such link found that is owned by this account.'); 99 | 100 | return Link.findById(link.id); 101 | }).then(link => { 102 | assert.equal(link.name, 'My Link'); 103 | }); 104 | }); 105 | it('should update a link with a new upstream', () => { 106 | // First, remove the upstream id from the link to test against. 107 | return issueRequest( 108 | update, [Link, isCollaboratorOfRepository], 109 | '/:linkId', user, { 110 | method: 'PUT', 111 | url: `/${link.id}`, 112 | json: true, 113 | body: { 114 | name: 'Another name for my link!', 115 | upstream: { 116 | type: 'repo', 117 | owner: 'foo', 118 | repo: 'bar', 119 | branches: ['master'], 120 | branch: 'master', 121 | }, 122 | fork: { 123 | type: 'fork-all', 124 | }, 125 | }, 126 | } 127 | ).then(res => { 128 | const body = res.body; 129 | assert.equal(body.id, link.id); 130 | assert.equal(body.name, 'Another name for my link!'); 131 | 132 | return Link.findById(link.id); 133 | }).then(link => { 134 | assert.equal(link.name, 'Another name for my link!'); 135 | }); 136 | }); 137 | it(`should try to update a link with a bad id.`, () => { 138 | // First, remove the upstream id from the link to test against. 139 | return issueRequest( 140 | update, [Link, isCollaboratorOfRepository], 141 | '/:linkId', user, { 142 | method: 'PUT', 143 | url: `/BOGUS-ID-HERE`, 144 | json: true, 145 | body: { 146 | name: 'Another name for my link!', 147 | upstream: { 148 | type: 'repo', 149 | owner: 'foo', 150 | repo: 'bar', 151 | branches: ['master'], 152 | branch: 'master', 153 | }, 154 | fork: { 155 | type: 'fork-all', 156 | }, 157 | }, 158 | } 159 | ).then(res => { 160 | const body = res.body; 161 | assert.equal(body.error, `No such link found that is owned by this account.`); 162 | }); 163 | }); 164 | it(`should try to update a link with a malformed body`, () => { 165 | // First, remove the upstream id from the link to test against. 166 | return issueRequest( 167 | update, [Link, isCollaboratorOfRepository], 168 | '/:linkId', user, { 169 | method: 'PUT', 170 | url: `/${link.id}`, 171 | json: true, 172 | body: [], 173 | } 174 | ).then(res => { 175 | const body = res.body; 176 | assert.equal(body.error, `Invalid json body.`); 177 | }); 178 | }); 179 | it(`should try to update a link with a valid body but no upstream`, () => { 180 | // First, remove the upstream id from the link to test against. 181 | return issueRequest( 182 | update, [Link, isCollaboratorOfRepository], 183 | '/:linkId', user, { 184 | method: 'PUT', 185 | url: `/${link.id}`, 186 | json: true, 187 | body: { 188 | name: 'Another name for my link!', 189 | /* NO UPSTREAM */ 190 | fork: { 191 | type: 'fork-all', 192 | }, 193 | }, 194 | } 195 | ).then(res => { 196 | const body = res.body; 197 | assert.equal(body.error, `Link doesn't have an 'upstream' key.`); 198 | }); 199 | }); 200 | it(`should try to update a link with a valid body but no fork`, () => { 201 | // First, remove the upstream id from the link to test against. 202 | return issueRequest( 203 | update, [Link, isCollaboratorOfRepository], 204 | '/:linkId', user, { 205 | method: 'PUT', 206 | url: `/${link.id}`, 207 | json: true, 208 | body: { 209 | name: 'Another name for my link!', 210 | upstream: { 211 | type: 'repo', 212 | owner: 'foo', 213 | repo: 'bar', 214 | branches: ['master'], 215 | branch: 'master', 216 | }, 217 | /* NO FORK */ 218 | }, 219 | } 220 | ).then(res => { 221 | const body = res.body; 222 | assert.equal(body.error, `Link doesn't have a 'fork' key.`); 223 | }); 224 | }); 225 | it(`should try to update a link with a valid body but an upstream that isn't a repo`, () => { 226 | // First, remove the upstream id from the link to test against. 227 | return issueRequest( 228 | update, [Link, isCollaboratorOfRepository], 229 | '/:linkId', user, { 230 | method: 'PUT', 231 | url: `/${link.id}`, 232 | json: true, 233 | body: { 234 | name: 'Another name for my link!', 235 | upstream: { 236 | type: 'fork-all', // <= An upstream must be a repo, so this should fail. 237 | }, 238 | fork: { 239 | type: 'fork-all', 240 | }, 241 | }, 242 | } 243 | ).then(res => { 244 | const body = res.body; 245 | assert.equal(body.error, `The 'upstream' repo must be a repo, not a bunch of forks.`); 246 | }); 247 | }); 248 | it(`should try to update a link (the link syncs to all forks), but the user isn't a collaborator on the upstream`, () => { 249 | return issueRequest( 250 | update, [Link, () => Promise.resolve(false)], 251 | '/:linkId', user, { 252 | method: 'PUT', 253 | url: `/${link.id}`, 254 | json: true, 255 | body: { 256 | name: 'Another name for my link!', 257 | upstream: { 258 | type: 'repo', 259 | owner: 'foo', 260 | repo: 'bar', 261 | isFork: false, 262 | branches: ['master'], 263 | branch: 'master', 264 | }, 265 | fork: { 266 | type: 'fork-all', 267 | }, 268 | }, 269 | }, 270 | ).then(res => { 271 | const body = res.body; 272 | assert.equal(body.error, `To update a link that syncs changes from the upstream foo/bar to all fork, you need to be a collaborator on foo/bar. Instead, sync to a single fork that you own instead of all forks.`); 273 | }); 274 | }); 275 | it(`should try to update a link (the link syncs to a single fork), but the user isn't a collaborator on the fork`, () => { 276 | return issueRequest( 277 | update, [Link, () => Promise.resolve(false)], 278 | '/:linkId', user, { 279 | method: 'PUT', 280 | url: `/${link.id}`, 281 | json: true, 282 | body: { 283 | name: 'Another name for my link!', 284 | upstream: { 285 | type: 'repo', 286 | owner: 'foo', 287 | repo: 'bar', 288 | isFork: false, 289 | branches: ['master'], 290 | branch: 'master', 291 | }, 292 | fork: { 293 | type: 'repo', 294 | owner: 'hello', 295 | repo: 'world', 296 | isFork: false, 297 | branches: ['master'], 298 | branch: 'master', 299 | }, 300 | }, 301 | }, 302 | ).then(res => { 303 | const body = res.body; 304 | assert.equal(body.error, `You need to be a collaborator of hello/world to sync changes to that fork.`); 305 | }); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /src/routes/webhook/manual/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:webhook:manual'); 3 | 4 | const MANUAL = 'MANUAL'; 5 | const API_URL = process.env.API_URL || `http://localhost:${process.env.PORT || 8000}`; 6 | 7 | export default async function webhook(req, res, Link, User, WebhookQueue) { 8 | // Note: it is purposeful we are not filtering by user below, since this endpoint is 9 | // unathenticated. 10 | const link = await Link.findOne({ 11 | where: {webhookId: req.params.linkId}, 12 | include: [{model: User, as: 'owner'}], 13 | }); 14 | 15 | // If the webhook is enabled, add it to the queue. 16 | if (link && link.enabled) { 17 | const enqueuedAs = await WebhookQueue.push({ 18 | type: MANUAL, 19 | user: link.owner, 20 | link, 21 | 22 | // Link a manual link in the queue back to the request that spawned it. 23 | fromRequest: req.headers['x-request-id'] || null, 24 | }); 25 | 26 | res.status(201).send({ 27 | message: 'Scheduled webhook.', 28 | enqueuedAs, 29 | statusUrl: `${API_URL}/v1/operations/${enqueuedAs}`, 30 | }); 31 | } else if (link) { 32 | res.status(400).send({error: `Link is not enabled!`}); 33 | } else { 34 | res.status(404).send({error: `No such link with the id ${req.params.linkId}`}); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/routes/webhook/manual/test.js: -------------------------------------------------------------------------------- 1 | import webhook from './index'; 2 | import assert from 'assert'; 3 | 4 | // Helper for mounting routes in an express app and querying them. 5 | // import db from '../../test-helpers/create-database-model-instances'; 6 | import issueRequest from '../../../test-helpers/issue-request'; 7 | import MockModel from '../../../test-helpers/mock-model'; 8 | 9 | const User = new MockModel(), 10 | Link = new MockModel([], {owner: User}); 11 | 12 | const MockWebhookQueue = { 13 | queue: [], 14 | reset() { 15 | this.queue = []; 16 | }, 17 | push(item) { 18 | const id = (new Date()).getTime(); 19 | this.queue.push({id, item}); 20 | return Promise.resolve(id); 21 | }, 22 | pop() { 23 | const popped = this.queue.pop(); 24 | return Promise.resolve(popped ? popped.item : null); 25 | }, 26 | }; 27 | 28 | describe('webhook tests', function() { 29 | let user, link, disabledLink; 30 | 31 | beforeEach(async function() { 32 | MockWebhookQueue.reset(); 33 | 34 | user = await User.create({username: 'ryan'}); 35 | link = await Link.create({ 36 | name: 'My Link', 37 | enabled: true, 38 | owner: user.id, 39 | webhookId: '123', 40 | 41 | upstreamType: 'repo', 42 | upstreamOwner: 'foo', 43 | upstreamRepo: 'bar', 44 | upstreamIsFork: false, 45 | upstreamBranches: '["master"]', 46 | upstreamBranch: 'master', 47 | 48 | forkType: 'all-forks', 49 | forkOwner: undefined, 50 | forkRepo: undefined, 51 | forkBranches: undefined, 52 | forkBranch: undefined, 53 | }); 54 | disabledLink = await Link.create({ 55 | name: 'My Link', 56 | enabled: false, 57 | owner: user.id, 58 | webhookId: '456', 59 | 60 | upstreamType: 'repo', 61 | upstreamOwner: 'foo', 62 | upstreamRepo: 'bar', 63 | upstreamIsFork: false, 64 | upstreamBranches: '["master"]', 65 | upstreamBranch: 'master', 66 | 67 | forkType: 'all-forks', 68 | forkOwner: undefined, 69 | forkRepo: undefined, 70 | forkBranches: undefined, 71 | forkBranch: undefined, 72 | }); 73 | }); 74 | 75 | it('should add to the queue', function() { 76 | return issueRequest( 77 | webhook, [Link, User, MockWebhookQueue], 78 | '/_:linkId', null, { 79 | method: 'POST', 80 | url: `/_${link.webhookId}`, 81 | json: true, 82 | headers: { 'x-request-id': 'AC120001:C5A6_AC120009:0050_5A229DF8_0004:0007' }, 83 | } 84 | ).then(res => { 85 | // Assert an item has been put into the queue. 86 | assert.equal(MockWebhookQueue.queue.length, 1); 87 | 88 | // Assert the item contains the right request id. 89 | assert.equal( 90 | MockWebhookQueue.queue[0].item.fromRequest, 91 | 'AC120001:C5A6_AC120009:0050_5A229DF8_0004:0007' 92 | ); 93 | 94 | // And that the response was correct. 95 | const enqueuedAs = MockWebhookQueue.queue[0].id; 96 | assert.equal(res.statusCode, 201); 97 | assert.deepEqual(res.body, { 98 | message: 'Scheduled webhook.', 99 | enqueuedAs, 100 | statusUrl: `http://localhost:8000/v1/operations/${enqueuedAs}`, 101 | }); 102 | }); 103 | }); 104 | it('should add to the queue with null as request id', function() { 105 | return issueRequest( 106 | webhook, [Link, User, MockWebhookQueue], 107 | '/_:linkId', null, { 108 | method: 'POST', 109 | url: `/_${link.webhookId}`, 110 | json: true, 111 | headers: { /* no request id */ }, 112 | } 113 | ).then(res => { 114 | // Assert an item has been put into the queue. 115 | assert.equal(MockWebhookQueue.queue.length, 1); 116 | 117 | // Assert the item contains the right request id. 118 | assert.equal(MockWebhookQueue.queue[0].fromRequest, null); 119 | 120 | // And that the response was correct. 121 | const enqueuedAs = MockWebhookQueue.queue[0].id; 122 | assert.equal(res.statusCode, 201); 123 | assert.deepEqual(res.body, { 124 | message: 'Scheduled webhook.', 125 | enqueuedAs, 126 | statusUrl: `http://localhost:8000/v1/operations/${enqueuedAs}`, 127 | }); 128 | }); 129 | }); 130 | it('should not add to the queue when a link is disabled', function() { 131 | return issueRequest( 132 | webhook, [Link, User, MockWebhookQueue], 133 | '/_:linkId', null, { 134 | method: 'POST', 135 | url: `/_${disabledLink.webhookId}`, 136 | json: true, 137 | } 138 | ).then(res => { 139 | // Assert an item has not been put into the queue. 140 | assert.equal(MockWebhookQueue.queue.length, 0); 141 | 142 | // And that the response was correct. 143 | assert.equal(res.body.error, 'Link is not enabled!'); 144 | }); 145 | }); 146 | it('should not add to the queue when a link does not exist', function() { 147 | return issueRequest( 148 | webhook, [Link, User, MockWebhookQueue], 149 | '/_:linkId', null, { 150 | method: 'POST', 151 | url: `/_bogusid`, 152 | json: true, 153 | } 154 | ).then(res => { 155 | // Assert an item has not been put into the queue. 156 | assert.equal(MockWebhookQueue.queue.length, 0); 157 | 158 | // And that the response was correct. 159 | assert.equal(res.body.error, 'No such link with the id bogusid'); 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/routes/webhook/status/index.js: -------------------------------------------------------------------------------- 1 | import Debug from 'debug'; 2 | const debug = Debug('backstroke:webhook:status'); 3 | 4 | const MANUAL = 'MANUAL'; 5 | 6 | export default async function webhook(req, res, WebhookStatus) { 7 | const response = await WebhookStatus.get(req.params.operationId); 8 | 9 | if (response) { 10 | return response; 11 | } else { 12 | res.status(403).send({ 13 | error: 'No such webhook operation was found with that id.', 14 | note: 'Your operation has either not started yet or is old and has been removed to free space.', 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/routes/webhook/status/test.js: -------------------------------------------------------------------------------- 1 | import status from './index'; 2 | import assert from 'assert'; 3 | 4 | // Helper for mounting routes in an express app and querying them. 5 | // import db from '../../test-helpers/create-database-model-instances'; 6 | import issueRequest from '../../../test-helpers/issue-request'; 7 | 8 | const MockWebhookStatusStore = { 9 | keys: {}, 10 | set(webhookId, status) { 11 | const id = (new Date()).getTime(); 12 | this.keys[webhookId] = {status, id}; 13 | return Promise.resolve(id); 14 | }, 15 | get(webhookId) { 16 | return Promise.resolve(this.keys[webhookId] ? this.keys[webhookId].status : null); 17 | }, 18 | }; 19 | 20 | // Example payloads to use in tests. 21 | const exampleOperationId = 'esr9z0fugfmYKy0T2RIignKv8wf6irk0'; 22 | const exampleOperationPayload = { 23 | status: 'RUNNING', 24 | startedAt: '2017-08-17T11:46:34.901Z', 25 | }; 26 | 27 | describe('webhook status', function() { 28 | beforeEach(async function() { 29 | // Reset the status store. 30 | MockWebhookStatusStore.keys = {}; 31 | }); 32 | 33 | it('should pull a webhook operation status from the status store', async function() { 34 | // Set the operation payload. 35 | await MockWebhookStatusStore.set(exampleOperationId, exampleOperationPayload); 36 | 37 | return issueRequest( 38 | status, [MockWebhookStatusStore], 39 | '/:operationId', null, { 40 | method: 'POST', 41 | url: `/${exampleOperationId}`, 42 | json: true, 43 | } 44 | ).then(res => { 45 | // Assert that the response was correct. 46 | assert.deepEqual(res.body, exampleOperationPayload); 47 | }); 48 | }); 49 | it(`should not pull a webhook operation status from the status store if the operation doesn't exist`, async function() { 50 | // Set the operation payload. 51 | await MockWebhookStatusStore.set(exampleOperationId, exampleOperationPayload); 52 | 53 | return issueRequest( 54 | status, [MockWebhookStatusStore], 55 | '/:operationId', null, { 56 | method: 'POST', 57 | url: `/bogusid`, 58 | json: true, 59 | } 60 | ).then(res => { 61 | // Assert that the response was correct. 62 | assert.equal(res.body.error, 'No such webhook operation was found with that id.'); 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/routes/whoami/index.js: -------------------------------------------------------------------------------- 1 | export default function whoami(req, res) { 2 | if (req.isAuthenticated()) { 3 | res.status(200).send(req.user); 4 | } else { 5 | res.status(401).send({error: 'Not logged in.'}); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | const app = express(); 3 | 4 | // ---------------------------------------------------------------------------- 5 | // Log requests as they are received. 6 | // ---------------------------------------------------------------------------- 7 | import morgan from 'morgan'; 8 | app.use(morgan((tokens, req, res) => { 9 | return [ 10 | tokens.method(req, res), 11 | tokens.url(req, res), 12 | tokens.status(req, res), 13 | tokens.res(req, res, 'content-length'), '-', 14 | tokens['response-time'](req, res), 'ms', 15 | // Add request id to logs 16 | req.headers['x-request-id'] ? `id=>${req.headers['x-request-id']}` : null, 17 | ].join(' '); 18 | })); 19 | 20 | // ---------------------------------------------------------------------------- 21 | // Define Cross Origin Resource Sharing rules. 22 | // ---------------------------------------------------------------------------- 23 | import cors from 'cors'; 24 | const corsHandler = cors({ 25 | origin(origin, callback) { 26 | if (!origin || origin.match(new RegExp(process.argv.CORS_ORIGIN_REGEXP, 'i'))) { 27 | callback(null, true); 28 | } else { 29 | callback(null, false); 30 | } 31 | }, 32 | credentials: true, 33 | }); 34 | app.use(corsHandler); 35 | app.options('*', corsHandler); 36 | 37 | // Polyfill promise with bluebird. 38 | import Promise from 'bluebird'; 39 | global.Promise = Promise; 40 | 41 | // How should we redirect to other origins? If unset, add some mocks to this app to use as those 42 | // redirects. 43 | const APP_URL = process.env.APP_URL || '/mocks/app'; 44 | if (APP_URL === '/mocks/app') { 45 | app.get('/mocks/app', (req, res) => res.send('This would redirect to the app when deployed.')); 46 | } 47 | const ROOT_URL = process.env.ROOT_URL || '/mocks/root'; 48 | if (APP_URL === '/mocks/root') { 49 | app.get('/mocks/root', (req, res) => res.send('This would redirect to the main site when deployed.')); 50 | } 51 | app.get('/', (req, res) => res.sendFile(__dirname+'/root-file.html')); 52 | 53 | // ---------------------------------------------------------------------------- 54 | // Routes and helpers for the routes 55 | // ---------------------------------------------------------------------------- 56 | import route from './helpers/route'; 57 | 58 | import whoami from './routes/whoami'; 59 | import checkRepo from './routes/checkRepo'; 60 | 61 | import manual from './routes/webhook/manual'; 62 | import status from './routes/webhook/status'; 63 | import { isCollaboratorOfRepository } from './routes/links/update/helpers'; 64 | 65 | import linksList from './routes/links/list'; 66 | import linksGet from './routes/links/get'; 67 | import linksGetOperations from './routes/links/get-operations'; 68 | import linksCreate from './routes/links/create'; 69 | import linksDelete from './routes/links/delete'; 70 | import linksUpdate from './routes/links/update'; 71 | import linksEnable from './routes/links/enable'; 72 | 73 | import { Link, User, WebhookQueue, WebhookStatusStore } from './models'; 74 | 75 | // ---------------------------------------------------------------------------- 76 | // Use sentry in production to report errors 77 | // ---------------------------------------------------------------------------- 78 | import Raven from 'raven'; 79 | if (process.env.SENTRY_CONFIG) { 80 | Raven.config(process.env.SENTRY_CONFIG).install(); 81 | } 82 | 83 | // ---------------------------------------------------------------------------- 84 | // Passport stuff 85 | // ---------------------------------------------------------------------------- 86 | import passport from 'passport'; 87 | import session from 'express-session'; 88 | import fileStore from 'session-file-store'; 89 | const FileStore = fileStore(session); 90 | import strategy from './auth/strategy'; 91 | import serialize from './auth/serialize'; 92 | app.use(session({ 93 | secret: process.env.SESSION_SECRET, 94 | saveUninitialized: true, 95 | resave: true, 96 | })); 97 | app.use(passport.initialize()); 98 | app.use(passport.session()); 99 | passport.use(strategy(User)); 100 | serialize(User, passport); 101 | 102 | // ---------------------------------------------------------------------------- 103 | // User authentication flow routes 104 | // ---------------------------------------------------------------------------- 105 | import bodyParser from 'body-parser'; 106 | 107 | // Authenticate a user 108 | app.get('/setup/login', passport.authenticate('github', { 109 | successRedirect: '/', 110 | scope: ["repo", "write:repo_hook", "user:email"], 111 | })); 112 | app.get('/setup/login/public', passport.authenticate('github', { 113 | successRedirect: '/', 114 | scope: ["public_repo", "write:repo_hook", "user:email"], 115 | })); 116 | 117 | // Second leg of the auth 118 | app.get("/auth/github/callback", passport.authenticate("github", { 119 | failureRedirect: '/setup/failed', 120 | }), (req, res) => { 121 | res.redirect(APP_URL); // on success 122 | }); 123 | 124 | // Second leg of the auth 125 | app.get("/auth/github-public/callback", passport.authenticate("github-public", { 126 | failureRedirect: '/setup/failed', 127 | }), (req, res) => { 128 | res.redirect(APP_URL); // on success 129 | }); 130 | 131 | app.get('/logout', (req, res) => { 132 | req.logout(); 133 | res.redirect(ROOT_URL); 134 | }); 135 | 136 | import analyticsForRoute from './helpers/mixpanel'; 137 | // A utility function to check if a user is authenticated, and if so, return 138 | // the authenticated user. Otherwise, this function will throw an error 139 | function assertLoggedIn(req, res, next) { 140 | if (req.isAuthenticated()) { 141 | next(); 142 | } else { 143 | res.status(401).send({error: 'Not authenticated.'}); 144 | } 145 | } 146 | 147 | import GitHubApi from 'github'; 148 | function constructor(github) { 149 | return { 150 | reposGet: Promise.promisify(github.repos.get), 151 | reposGetBranch: Promise.promisify(github.repos.getBranch), 152 | reposGetBranches: Promise.promisify(github.repos.getBranches), 153 | reposGetForks: Promise.promisify(github.repos.getForks), 154 | reposFork: Promise.promisify(github.repos.fork), 155 | reposEdit: Promise.promisify(github.repos.edit), 156 | reposDelete: Promise.promisify(github.repos['delete']), 157 | reposMerge: Promise.promisify(github.repos.merge), 158 | reposAddCollaborator: Promise.promisify(github.repos.addCollaborator), 159 | 160 | pullRequestsCreate: Promise.promisify(github.pullRequests.create), 161 | pullRequestsGetAll: Promise.promisify(github.pullRequests.getAll), 162 | pullRequestsMerge: Promise.promisify(github.pullRequests.merge), 163 | 164 | reposCreateHook: Promise.promisify(github.repos.createHook), 165 | reposDeleteHook: Promise.promisify(github.repos.deleteHook), 166 | reposFork: Promise.promisify(github.repos.fork), 167 | reposGetCollaborators: Promise.promisify(github.repos.getCollaborators), 168 | searchIssues: Promise.promisify(github.search.issues), 169 | }; 170 | } 171 | 172 | // Authorize the bot. 173 | const bot = new GitHubApi({}); 174 | bot.authenticate({ 175 | type: "oauth", 176 | token: process.env.GITHUB_TOKEN, 177 | }); 178 | 179 | // An express middleware that adds a github api instance to the request for the currently 180 | // authenticated user. If no user is logged in, the property isn't set. 181 | function authedGithubInstance(req, res, next) { 182 | req.github = {}; 183 | 184 | // Add the bot api instance to the request. 185 | req.github.bot = constructor(bot); 186 | 187 | // If a user is logged in, create an add a user instance. 188 | if (req.user) { 189 | const github = new GitHubApi({}); 190 | github.authenticate({ 191 | type: "oauth", 192 | token: req.user.accessToken, 193 | }); 194 | 195 | req.github.user = constructor(github); 196 | } 197 | return next(); 198 | } 199 | 200 | // ---------------------------------------------------------------------------- 201 | // User authentication flow routes 202 | // ---------------------------------------------------------------------------- 203 | 204 | // Redirect calls to `/api/v1` => `/v1` 205 | app.all(/^\/api\/v1\/.*$/, (req, res) => res.redirect(req.url.replace(/^\/api/, ''))); 206 | 207 | // Identify the currently logged in user 208 | app.get('/v1/whoami', whoami); 209 | 210 | // GET all links 211 | app.get('/v1/links', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksList, [Link])); 212 | 213 | // GET a single link 214 | app.get('/v1/links/:id', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksGet, [Link])); 215 | 216 | // GET all operations associated with a single link 217 | app.get('/v1/links/:id/operations', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksGetOperations, [Link, WebhookStatusStore])); 218 | 219 | // Create a new link 220 | app.post('/v1/links', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksCreate, [Link])); 221 | 222 | // delete a link 223 | app.delete('/v1/links/:id', assertLoggedIn, analyticsForRoute, route(linksDelete, [Link])); 224 | 225 | // POST link updates 226 | app.post('/v1/links/:linkId', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksUpdate, [Link, isCollaboratorOfRepository])); 227 | 228 | // enable or disable a link 229 | app.post('/v1/links/:linkId/enable', bodyParser.json(), assertLoggedIn, analyticsForRoute, route(linksEnable, [Link])); 230 | 231 | // return the branches for a given repo 232 | app.get('/v1/repos/:provider/:user/:repo', bodyParser.json(), assertLoggedIn, authedGithubInstance, checkRepo); 233 | 234 | // the new webhook route - both the condensed verson meant to be called by a user and the interal 235 | // variant that follows REST a bit closer. 236 | app.all('/_:linkId', route(manual, [Link, User, WebhookQueue])); 237 | app.post('/v1/links/:linkId/sync', assertLoggedIn, route(manual, [Link, User, WebhookQueue])); 238 | 239 | // check to see how a link operation is doing after it has been kicked off 240 | app.get('/v1/operations/:operationId', route(status, [WebhookStatusStore])); 241 | 242 | if (require.main === module) { 243 | const port = process.env.PORT || 8001; 244 | app.listen(port); 245 | console.log('Listening on port', port); 246 | } 247 | -------------------------------------------------------------------------------- /src/test-helpers/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/backstrokeapp/server/e234711b2c59b706a645175b0fee41017681d2c5/src/test-helpers/.DS_Store -------------------------------------------------------------------------------- /src/test-helpers/create-database-model-instances.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker'; 2 | import {Schema} from 'jugglingdb'; 3 | import Promise from 'bluebird'; 4 | 5 | import linkBuilder from '../../src/models/Link'; 6 | import userBuilder from '../../src/models/User'; 7 | import repositoryBuilder from '../../src/models/Repository'; 8 | 9 | const schema = new Schema('memory'); 10 | const User = userBuilder(schema); 11 | const Repository = repositoryBuilder(schema); 12 | const Link = linkBuilder(schema); 13 | 14 | // A class to abstract away makind database models in tests. I'm abstrating this away because if the 15 | // database model ever changes (READ: fields added), I want all my tests to continue to work. 16 | export default { 17 | Link, 18 | User, 19 | Repository, 20 | 21 | reset() { 22 | return Promise.all([ 23 | Link.destroyAll(), 24 | User.destroyAll(), 25 | Repository.destroyAll(), 26 | ]); 27 | }, 28 | 29 | makeUser({username, email}={}) { 30 | username = username || faker.internet.userName(); 31 | return User.create({ 32 | username, 33 | email: email || faker.internet.email(), 34 | picture: faker.image.imageUrl(), 35 | providerId: faker.random.number(), 36 | accessToken: faker.random.uuid().replace(/-/, ''), 37 | }); 38 | }, 39 | 40 | makeRepository(type, {owner, repo, fork, branches, branch}={}) { 41 | if (type === 'fork-all') { 42 | return Repository.create({ 43 | type: 'fork-all', 44 | }); 45 | } else if (type === 'repo') { 46 | owner = owner || faker.internet.userName(); 47 | repo = repo || faker.random.word(); 48 | fork = fork || false; 49 | branch = branch || faker.random.word(); 50 | return Repository.create({ 51 | type: 'repo', 52 | owner, 53 | repo, 54 | fork, 55 | branches: branches || [branch, ...Array(faker.random.number()).fill(0).map(i => faker.random.word())], 56 | branch, 57 | }); 58 | } else { 59 | throw new Error(`Required arg 'type' isn't valid: should be repo or fork-all`); 60 | } 61 | }, 62 | 63 | makeLink({name, enabled, hookId, owner, upstream, fork}={}) { 64 | let operations = {upstream, fork}; 65 | 66 | if (typeof upstream === 'object' && upstream.hasOwnProperty) { 67 | operations.upstream = this.makeRepository('repo', upstream).then(repo => { 68 | return repo.id; 69 | }); 70 | } 71 | 72 | if (typeof fork === 'object' && fork.hasOwnProperty) { 73 | operations.fork = this.makeRepository('repo', fork).then(repo => { 74 | return repo.id; 75 | }); 76 | } 77 | 78 | return Promise.props(operations).then(({fork, upstream}) => { 79 | return Link.create({ 80 | name: name || faker.internet.userName(), 81 | enabled: enabled || false, 82 | hookId: hookId || Array(faker.random.number(5)).fill(0).map(i => faker.random.number()), 83 | ownerId: owner, 84 | upstreamId: upstream, 85 | forkId: fork, 86 | }); 87 | }); 88 | }, 89 | } 90 | -------------------------------------------------------------------------------- /src/test-helpers/create-mock-github-instance.js: -------------------------------------------------------------------------------- 1 | let _existingIds = []; 2 | const MAX_ID = 10000000; 3 | export function generateId() { 4 | let id; 5 | do { 6 | id = Math.floor(Math.random() * MAX_ID); 7 | } while (_existingIds.indexOf(id) !== -1); 8 | 9 | _existingIds.push(id); 10 | return id; 11 | } 12 | 13 | export function generateSha() { 14 | let sha = ''; 15 | for (let i = 0; i <= 40; i++) { 16 | sha += String.fromCharCode(Math.floor(Math.random() * 25) + 97); 17 | } 18 | return sha; 19 | } 20 | 21 | 22 | export function generateOwner(owner, type="User") { 23 | let id = generateId(); 24 | return { 25 | id, 26 | type, 27 | login: owner, 28 | avatar_url: `https://avatars0.githubusercontent.com/u/${id}?v=3`, 29 | site_admin: false, 30 | } 31 | } 32 | 33 | const BASE_REPO = { 34 | owner: 'foo', 35 | name: 'bar', 36 | isFork: false, 37 | branches: [ 38 | {name: 'master', commit: {sha: generateSha()}}, 39 | ], 40 | }; 41 | export function generateRepo(addons) { 42 | let {owner, name, isFork, branches, forks, issues, pullRequests, webhooks, parent} = Object.assign({}, BASE_REPO, addons); 43 | let id = generateId(); 44 | return { 45 | id, 46 | name, 47 | full_name: `${owner}/${name}`, 48 | owner: generateOwner(owner), 49 | parent, 50 | private: false, 51 | html_url: `https://github.com/${owner}/${name}`, 52 | description: "I am a mock repository created for testing purposes.", 53 | fork: isFork, 54 | url: `https://api.github.com/repos/${owner}/${name}`, 55 | stargazers_count: Math.floor(Math.random() * 500), 56 | watchers_count: Math.floor(Math.random() * 500), 57 | language: null, 58 | forks_count: (forks || []).length, 59 | forks: (forks || []).length, 60 | organization: generateOwner(owner), 61 | 62 | _branches: branches || [], 63 | _forks: forks || [], 64 | _issues: issues || [], 65 | _pullRequests: pullRequests || [], 66 | _webhooks: webhooks || [], 67 | }; 68 | } 69 | 70 | export default function createMockGithubInstance(repoDirectory) { 71 | return { 72 | _repoDirectory: repoDirectory, 73 | 74 | reposGet({owner, repo}) { 75 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 76 | if (r) { 77 | return Promise.resolve(r); 78 | } else { 79 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 80 | } 81 | }, 82 | reposGetBranches({owner, repo}) { 83 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 84 | if (r) { 85 | return Promise.resolve(r._branches); 86 | } else { 87 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 88 | } 89 | }, 90 | reposGetForks({owner, repo}) { 91 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 92 | if (r) { 93 | return Promise.resolve(r._forks); 94 | } else { 95 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 96 | } 97 | }, 98 | pullRequestsCreate({owner, repo, title, head, base, body, maintainer_can_modify}) { 99 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 100 | if (r) { 101 | let doesPRAlreadyExist = r._pullRequests.find(i => i.head.label === head && i.base.label === base); 102 | if (doesPRAlreadyExist) { 103 | return Promise.reject({code: 422, error: 'Pull request already exists'}); 104 | } 105 | 106 | let pr = { 107 | id: generateId(), 108 | number: Math.floor(Math.random() * 1000), 109 | state: "open", 110 | title, 111 | body, 112 | assignee: null, 113 | milestone: null, 114 | locked: false, 115 | created_at: (new Date()).toISOString(), 116 | updated_at: (new Date()).toISOString(), 117 | labels: [], 118 | head: { 119 | label: head, 120 | ref: head.split(':').reverse()[0], 121 | sha: generateSha(), 122 | }, 123 | base: { 124 | label: base, 125 | ref: base.split(':').reverse()[0], 126 | sha: generateSha(), 127 | }, 128 | user: null, 129 | }; 130 | 131 | r._issues = [...r._issues, { 132 | id: pr.id, 133 | number: pr.number, 134 | state: pr.state, 135 | title: pr.title, 136 | body: pr.body, 137 | user: null, 138 | labels: [], 139 | locked: false, 140 | assignee: null, 141 | assignees: [], 142 | milestone: null, 143 | comments: 0, 144 | closed_at: null, 145 | pull_request: { 146 | url: `https://api.github.com/repos/1egoman/backstroke/pulls/${pr.number}`, 147 | html_url: `https://github.com/1egoman/backstroke/pull/${pr.number}`, 148 | diff_url: `https://github.com/1egoman/backstroke/pull/${pr.number}.diff`, 149 | patch_url: `https://github.com/1egoman/backstroke/pull/${pr.number}.patch` 150 | }, 151 | }]; 152 | 153 | r._pullRequests = [...r._pullRequests, pr]; 154 | 155 | return Promise.resolve(pr); 156 | } else { 157 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 158 | } 159 | }, 160 | pullRequestsGetAll({owner, repo}) { 161 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 162 | if (r) { 163 | return Promise.resolve(r._pullRequests); 164 | } else { 165 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 166 | } 167 | }, 168 | reposCreateHook({owner, repo, config, name, events}) { 169 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 170 | if (r) { 171 | let webhook = { 172 | id: generateId(), 173 | name, 174 | events, 175 | active: true, 176 | config, 177 | created_at: (new Date()).toISOString(), 178 | updated_at: (new Date()).toISOString(), 179 | }; 180 | r._webhooks = [...r._webhooks, webhook]; 181 | return Promise.resolve(webhook); 182 | } else { 183 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 184 | } 185 | }, 186 | reposDeleteHook({owner, repo, id}) { 187 | let r = repoDirectory.find(i => i.owner.login === owner && i.name === repo); 188 | if (r) { 189 | let matchingWebhook = r._webhooks.findIndex(i => i.id === id); 190 | if (matchingWebhook >= 0) { 191 | r._webhooks.splice(matchingWebhook, 1); 192 | return Promise.resolve(); 193 | } else { 194 | return Promise.reject(new Error(`No such webhook ${id} on repo ${owner}/${repo}`)); 195 | } 196 | } else { 197 | return Promise.reject(new Error(`No such repo ${owner}/${repo}`)); 198 | } 199 | }, 200 | searchIssues({q}) { 201 | let matches = `${q} `.match(/(.+?):(.+?) /g); 202 | 203 | if (matches.length === 0) { 204 | return Promise.resolve(all); 205 | } 206 | 207 | // Get all issues from the list of repos, by concating them together. 208 | // Since we want to still have the context of the repo attaches to the issue, add the repo as 209 | // the _repo property on each issue to be referenced later. 210 | let allIssues = repoDirectory.reduce((acc, i) => [ 211 | ...acc, 212 | ...i._issues.map(j => Object.assign({}, j, {_repo: i})), 213 | ], []); 214 | 215 | let items = matches.reduce((all, query) => { 216 | let [name, value] = query.trim().split(':'); 217 | 218 | switch (name) { 219 | case 'repo': // repo:foo/bar 220 | let [owner, repo] = value.split('/'); 221 | return all.filter(i => i._repo.owner.login === owner && i._repo.name === repo); 222 | case 'is': // is:pr 223 | if (value === 'pr') { 224 | return all.filter(i => !i.pull_request); 225 | } else if (value === 'issue') { 226 | return all.filter(i => i.pull_request); 227 | } else { 228 | return all; 229 | } 230 | case 'label': // label:my-custom-label 231 | return all.filter(i => i.labels.find(j => j.name === value)); 232 | default: 233 | return all; 234 | } 235 | }, allIssues).map(i => { 236 | delete i._repo; 237 | return i; 238 | }); 239 | 240 | return Promise.resolve({total_count: items.length, items}); 241 | } 242 | }; 243 | } 244 | -------------------------------------------------------------------------------- /src/test-helpers/issue-request.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import request from 'request'; 6 | 7 | import route from '../helpers/route'; 8 | 9 | // A helper to mount a given route to a mock express app and query the endpoint, mainly useful for 10 | // testing. 11 | // 12 | // function myRoute(Dependency, req, res) { 13 | // res.send('foo'); 14 | // } 15 | // 16 | // issueRequest( 17 | // myRoute, // The route to substitute in 18 | 19 | // [Dependency], // A list of dependencies to inject prior to the route. 20 | 21 | // '/', // Where to mount the route at. This is an option because you may want to 22 | // // mount at something other than '/', ie, you need request an id passed in 23 | // // via `req.params` and that requires the url to be `/:id`. 24 | 25 | // null, // A user object to be put into req.user. `null` signifies no authed user. 26 | 27 | // { // Finally, custom parameters to make the request with, like headers, body, etc 28 | // method: 'GET', 29 | // url: '/', 30 | // } 31 | // ).then(res => { 32 | // // `res` is a response from the `request` package, and has fields like `res.statusCode` 33 | // // and `res.body`. 34 | // }); 35 | export default function issueRequest(fn, deps, mountAt='/', user=null, requestParameters={url: '/'}) { 36 | return new Promise((resolve, reject) => { 37 | // Create a unix socket to mount the server at 38 | const socketPath = path.join(process.cwd(), `backstroke-test-socket-${process.pid}.sock`); 39 | if (fs.existsSync(socketPath)) { 40 | fs.unlinkSync(socketPath); 41 | } 42 | 43 | // Create a server with the function mounted at `/` 44 | let app = express(); 45 | app.use(bodyParser.json()); 46 | app.use((req, res, next) => { 47 | req.user = user; 48 | req.isAuthenticated = () => req.user ? true : false; 49 | next(); 50 | }); 51 | app.all(mountAt, route(fn, deps)); 52 | 53 | // Listen on a local socket 54 | app.listen(socketPath, () => { 55 | requestParameters = Object.assign({}, requestParameters, { 56 | url: `http://unix:${socketPath}:${requestParameters.url}`, 57 | }); 58 | return request(requestParameters, (err, resp) => { 59 | // After making the request, delete the socket. 60 | fs.unlinkSync(socketPath); 61 | 62 | if (err) { 63 | reject(err); 64 | } else { 65 | resolve(resp); 66 | } 67 | }); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /src/test-helpers/mock-model.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | 3 | // A mock model that is used for testing. Extend your custom model from this and use it when testing 4 | // in place of the real model. 5 | export default class MockModel { 6 | constructor(models, foreignKeyNames={}) { 7 | // A list of all models that exist. 8 | this.models = [] 9 | if (models) { 10 | models.forEach(this.create.bind(this)); 11 | } 12 | this.foreignKeyNames = foreignKeyNames; 13 | this.idCounter = 0; 14 | 15 | this.methods = []; 16 | } 17 | 18 | _handleQueries({where, limit, include, offset}) { 19 | let models = this.models; 20 | 21 | if (where) { 22 | models = models.filter(model => { 23 | for (const key in where) { 24 | if (where[key] !== model[key]) { 25 | return false; 26 | } 27 | } 28 | return true; 29 | }); 30 | } 31 | 32 | if (offset) { 33 | models = models.slice(offset) 34 | } 35 | 36 | if (limit) { 37 | models = models.slice(0, limit) 38 | } 39 | 40 | if (include) { 41 | include.forEach(({model, as}) => { 42 | // Add the foreign key to the query specified with `include`. 43 | models = models.map(m => { 44 | const output = model.models.find(i => i.id === m[`${as}Id`]); 45 | if (output) { 46 | return {...m, [as]: output}; 47 | } else { 48 | throw new Error(`No such model ${as} found with the id ${i.id}`); 49 | } 50 | }); 51 | }); 52 | } 53 | return models; 54 | } 55 | 56 | findOne(query) { 57 | const model = this._handleQueries({...query, limit: 1}); 58 | return Promise.resolve(model.length ? this.formatModelInstance(model[0]) : null); 59 | } 60 | findAll(query) { 61 | const models = this._handleQueries(query); 62 | return Promise.all(models.map(this.formatModelInstance.bind(this))); 63 | } 64 | update(data, query) { 65 | // Get models to update 66 | const models = this._handleQueries(query).map(i => i.id); 67 | 68 | // Perform update 69 | this.models = this.models.map(model => { 70 | if (models.indexOf(model.id) >= 0) { 71 | return Object.assign({}, model, data); 72 | } else { 73 | return model; 74 | } 75 | }); 76 | 77 | // Resolve the number of changed items. 78 | return Promise.resolve([models.length]); 79 | } 80 | destroy(query) { 81 | // Get models to update 82 | const models = this._handleQueries(query).map(i => i.id); 83 | 84 | // Remove all matching models from the collection. 85 | this.models = this.models.filter(model => models.indexOf(model.id) === -1); 86 | 87 | // Resolve the number of changed items. 88 | return Promise.resolve(models.length); 89 | } 90 | findById(id) { 91 | const model = this._handleQueries({where: {id}, limit: 1}); 92 | return Promise.resolve(model.length ? this.formatModelInstance(model[0]) : null); 93 | } 94 | create(data) { 95 | data.id = (++this.idCounter).toString(); 96 | 97 | // Any foriegn keys get a `Id` suffixed version too. 98 | for (const fkey in this.foreignKeyNames) { 99 | data[fkey+'Id'] = data[fkey]; 100 | } 101 | 102 | this.models.push(data); 103 | return Promise.resolve(data); 104 | } 105 | all() { 106 | return Promise.all(this.models.map(this.formatModelInstance.bind(this))); 107 | } 108 | 109 | // Before returning an instance, run it through this function to populate any of the foriegn key 110 | // fields. 111 | formatModelInstance(instance) { 112 | const all = Object.keys(this.foreignKeyNames).reduce((acc, fkey) => { 113 | const Model = this.foreignKeyNames[fkey]; 114 | return [...acc, Model.findOne({where: {id: instance[`${fkey}Id`]}}).then(data => { 115 | instance[fkey] = data; 116 | })]; 117 | }, []); 118 | 119 | // Add all the methods to this item. 120 | for (const method in this.methods) { 121 | instance[method] = this.methods[method].bind(instance); 122 | } 123 | 124 | return Promise.all(all).then(() => { 125 | return instance; 126 | }); 127 | } 128 | } 129 | --------------------------------------------------------------------------------