From 9007658fd007fa8a4f5bb30f93b459f2a69332ba Mon Sep 17 00:00:00 2001
From: Paul Melnikow <github@paulmelnikow.com>
Date: Sat, 11 Aug 2018 20:13:40 -0400
Subject: [PATCH] Refactor and test [github] token persistence (#1863)

Ref #1848 #1205
---
 lib/github-auth.js                      | 74 ++++++++------------
 lib/in-process-server-test-helpers.js   |  4 +-
 lib/service-test-runner/cli.js          |  4 +-
 lib/text-formatters.spec.js             |  6 +-
 lib/token-persistence.js                | 45 ++++++++++++
 lib/token-persistence.spec.js           | 93 +++++++++++++++++++++++++
 package-lock.json                       | 15 ++++
 package.json                            |  5 +-
 server.js                               |  8 ++-
 services/github/github-constellation.js | 19 +++--
 10 files changed, 213 insertions(+), 60 deletions(-)
 create mode 100644 lib/token-persistence.js
 create mode 100644 lib/token-persistence.spec.js

diff --git a/lib/github-auth.js b/lib/github-auth.js
index 8a6f2b55ef..2c4b456c92 100644
--- a/lib/github-auth.js
+++ b/lib/github-auth.js
@@ -5,40 +5,21 @@ const log = require('./log')
 const secretIsValid = require('./sys/secret-is-valid')
 const queryString = require('query-string')
 const request = require('request')
-const autosave = require('json-autosave')
 const serverSecrets = require('./server-secrets')
 const mapKeys = require('lodash.mapkeys')
 
-// This is an initial value which makes the code work while the initial data
-// is loaded. In the then() callback of scheduleAutosaving(), it's reassigned
-// with a JsonSave object.
-let githubUserTokens = { data: [] }
-
-function scheduleAutosaving(githubUserTokensFile) {
-  autosave(githubUserTokensFile, { data: [] })
-    .then(save => {
-      githubUserTokens = save
-      for (let i = 0; i < githubUserTokens.data.length; i++) {
-        addGithubToken(githubUserTokens.data[i])
-      }
-      // Personal tokens allow access to GitHub private repositories.
-      // You can manage your personal GitHub token at
-      // <https://github.com/settings/tokens>.
-      if (serverSecrets && serverSecrets.gh_token) {
-        addGithubToken(serverSecrets.gh_token)
-      }
-    })
-    .catch(e => {
-      console.error('Could not create ' + githubUserTokensFile)
-    })
-}
+const userTokenRateLimit = 12500
 
-function cancelAutosaving() {
-  if (githubUserTokens.stop) {
-    githubUserTokens.stop()
-    githubUserTokens.save()
-    githubUserTokens = { data: [] }
-  }
+let githubUserTokens = []
+// Ideally, we would want priority queues here.
+const reqRemaining = new Map() // From token to requests remaining.
+const reqReset = new Map() // From token to timestamp.
+
+// Personal tokens allow access to GitHub private repositories.
+// You can manage your personal GitHub token at
+// <https://github.com/settings/tokens>.
+if (serverSecrets && serverSecrets.gh_token) {
+  addGithubToken(serverSecrets.gh_token)
 }
 
 function setRoutes(server) {
@@ -166,12 +147,6 @@ function sendTokenToAllServers(token) {
   )
 }
 
-// Track rate limit requests remaining.
-
-// Ideally, we would want priority queues here.
-const reqRemaining = new Map() // From token to requests remaining.
-const reqReset = new Map() // From token to timestamp.
-
 // token: client token as a string.
 // reqs: number of requests remaining.
 // reset: timestamp when the number of remaining requests is reset.
@@ -189,8 +164,6 @@ function utcEpochSeconds() {
   return (Date.now() / 1000) >>> 0
 }
 
-const userTokenRateLimit = 12500
-
 // Return false if the token cannot reasonably be expected to perform
 // a GitHub request.
 function isTokenUsable(token, now) {
@@ -206,7 +179,7 @@ function isTokenUsable(token, now) {
 // with a reasonable chance that the request will succeed.
 function usableTokens() {
   const now = utcEpochSeconds()
-  return githubUserTokens.data.filter(function(token) {
+  return githubUserTokens.filter(function(token) {
     return isTokenUsable(token, now)
   })
 }
@@ -235,17 +208,17 @@ function addGithubToken(token) {
   // A reset date of 0 has to be in the past.
   setReqRemaining(token, userTokenRateLimit, 0)
   // Insert it only if it is not registered yet.
-  if (githubUserTokens.data.indexOf(token) === -1) {
-    githubUserTokens.data.push(token)
+  if (githubUserTokens.indexOf(token) === -1) {
+    githubUserTokens.push(token)
   }
 }
 
 function rmGithubToken(token) {
   rmReqRemaining(token)
   // Remove it only if it is in there.
-  const idx = githubUserTokens.data.indexOf(token)
+  const idx = githubUserTokens.indexOf(token)
   if (idx >= 0) {
-    githubUserTokens.data.splice(idx, 1)
+    githubUserTokens.splice(idx, 1)
   }
 }
 
@@ -266,12 +239,20 @@ function sha(str) {
     .digest('hex')
 }
 
+function getAllTokenIds() {
+  return githubUserTokens.slice()
+}
+
+function removeAllTokens() {
+  githubUserTokens = []
+}
+
 function serializeDebugInfo(options) {
   // Apply defaults.
   const { sanitize } = Object.assign({ sanitize: true }, options)
 
   const unsanitized = {
-    tokens: githubUserTokens.data,
+    tokens: githubUserTokens,
     reqRemaining: mapToObject(reqRemaining),
     reqReset: mapToObject(reqReset),
     utcEpochSeconds: utcEpochSeconds(),
@@ -347,9 +328,10 @@ function githubRequest(request, url, query, cb) {
 }
 
 module.exports = {
-  scheduleAutosaving,
-  cancelAutosaving,
   request: githubRequest,
   setRoutes,
   serializeDebugInfo,
+  addGithubToken,
+  getAllTokenIds,
+  removeAllTokens,
 }
diff --git a/lib/in-process-server-test-helpers.js b/lib/in-process-server-test-helpers.js
index 2187427362..1b2f90bd17 100644
--- a/lib/in-process-server-test-helpers.js
+++ b/lib/in-process-server-test-helpers.js
@@ -56,9 +56,9 @@ function reset(server) {
  *
  * @param {Object} server instance
  */
-function stop(server) {
+async function stop(server) {
   if (server) {
-    server.stop()
+    await server.stop()
   }
 }
 
diff --git a/lib/service-test-runner/cli.js b/lib/service-test-runner/cli.js
index 9336f1b0d9..8a219e0d9c 100644
--- a/lib/service-test-runner/cli.js
+++ b/lib/service-test-runner/cli.js
@@ -57,8 +57,8 @@ before('Start running the server', function() {
   this.timeout(5000)
   server = serverHelpers.start()
 })
-after('Shut down the server', function() {
-  serverHelpers.stop(server)
+after('Shut down the server', async function() {
+  await serverHelpers.stop(server)
 })
 
 const runner = new Runner()
diff --git a/lib/text-formatters.spec.js b/lib/text-formatters.spec.js
index a4885dd658..6ea7793efc 100644
--- a/lib/text-formatters.spec.js
+++ b/lib/text-formatters.spec.js
@@ -83,8 +83,12 @@ describe('Text formatters', function() {
   })
 
   context('in october', function() {
+    let clock
     beforeEach(function() {
-      sinon.useFakeTimers(new Date(2017, 9, 15).getTime())
+      clock = sinon.useFakeTimers(new Date(2017, 9, 15).getTime())
+    })
+    afterEach(function() {
+      clock.restore()
     })
 
     test(formatDate, () => {
diff --git a/lib/token-persistence.js b/lib/token-persistence.js
new file mode 100644
index 0000000000..11356a16fc
--- /dev/null
+++ b/lib/token-persistence.js
@@ -0,0 +1,45 @@
+'use strict'
+
+const autosave = require('json-autosave')
+const githubAuth = require('./github-auth')
+
+// This is currently bound to the legacy github auth code. That will be
+// replaced with a dependency-injected token provider.
+class TokenPersistence {
+  constructor({ path }) {
+    this.path = path
+    this.save = undefined
+  }
+
+  async initialize() {
+    // This code is a bit difficult to understand, in part because it's
+    // working against the interface of `json-autosave` which wants to own the
+    // data structure.
+    const save = await autosave(this.path, { data: [] })
+    this.save = save
+
+    save.data.forEach(tokenString => {
+      githubAuth.addGithubToken(tokenString)
+    })
+
+    // Override the autosave handler to refresh the token data before
+    // saving.
+    save.autosave = () => {
+      save.data = githubAuth.getAllTokenIds()
+      return save.save()
+    }
+    // Put the change in autosave handler into effect.
+    save.stop()
+    save.start()
+  }
+
+  async stop() {
+    if (this.save) {
+      this.save.stop()
+      await this.save.autosave()
+      this.save = undefined
+    }
+  }
+}
+
+module.exports = TokenPersistence
diff --git a/lib/token-persistence.spec.js b/lib/token-persistence.spec.js
new file mode 100644
index 0000000000..6f44b65566
--- /dev/null
+++ b/lib/token-persistence.spec.js
@@ -0,0 +1,93 @@
+'use strict'
+
+const fs = require('fs')
+const tmp = require('tmp')
+const readFile = require('fs-readfile-promise')
+const sinon = require('sinon')
+const { sleep } = require('wait-promise')
+const { expect } = require('chai')
+const TokenPersistence = require('./token-persistence')
+const githubAuth = require('./github-auth')
+
+describe('Token persistence', function() {
+  // Fake timers must be set up before any timers are scheduled.
+  let clock
+  beforeEach(function() {
+    clock = sinon.useFakeTimers()
+  })
+  afterEach(function() {
+    clock.restore()
+  })
+
+  beforeEach(githubAuth.removeAllTokens)
+  afterEach(githubAuth.removeAllTokens)
+
+  let path, persistence
+  beforeEach(function() {
+    path = tmp.tmpNameSync()
+    persistence = new TokenPersistence({ path })
+  })
+  afterEach(function() {
+    if (persistence) {
+      persistence.stop()
+      persistence = null
+    }
+  })
+
+  context('when the file does not exist', function() {
+    it('creates it with an empty array', async function() {
+      await persistence.initialize()
+      const json = JSON.parse(await readFile(path))
+
+      expect(json).to.deep.equal([])
+    })
+  })
+
+  context('when the file exists', function() {
+    it('loads the contents', async function() {
+      const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40))
+      fs.writeFileSync(path, JSON.stringify(initialTokens))
+
+      await persistence.initialize()
+
+      expect(githubAuth.getAllTokenIds()).to.deep.equal(initialTokens)
+    })
+  })
+
+  context('when shutting down', function() {
+    it('writes added tokens to the file', async function() {
+      const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40))
+      fs.writeFileSync(path, JSON.stringify(initialTokens))
+
+      const newToken = 'e'.repeat(40)
+      const expected = initialTokens.slice()
+      expected.push(newToken)
+
+      await persistence.initialize()
+      githubAuth.addGithubToken(newToken)
+      await persistence.stop()
+
+      const json = JSON.parse(await readFile(path))
+      expect(json).to.deep.equal(expected)
+    })
+  })
+
+  context('time has elapsed', function() {
+    it('writes added tokens to the file', async function() {
+      const addedTokens = ['d', 'e'].map(char => char.repeat(40))
+
+      await persistence.initialize()
+
+      addedTokens.forEach(githubAuth.addGithubToken)
+
+      // Fake time passing to trigger autosaving, then give the save a brief
+      // moment of real time to complete before reading.
+      clock.tick(5000)
+      clock.restore()
+      await sleep(200)
+
+      const json = JSON.parse(await readFile(path))
+      expect(json).to.deep.equal(addedTokens)
+    })
+  })
+})
diff --git a/package-lock.json b/package-lock.json
index 205fc06573..8cfefb7e1b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5762,6 +5762,15 @@
       "integrity": "sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=",
       "dev": true
     },
+    "fs-readfile-promise": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-3.0.1.tgz",
+      "integrity": "sha512-LsSxMeaJdYH27XrW7Dmq0Gx63mioULCRel63B5VeELYLavi1wF5s0XfsIdKDFdCL9hsfQ2qBvXJszQtQJ9h17A==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.11"
+      }
+    },
     "fs-write-stream-atomic": {
       "version": "1.0.10",
       "resolved": "https://registry.npmjs.org/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz",
@@ -14648,6 +14657,12 @@
       "integrity": "sha512-8Xz4H3vhYRGbFupLtl6dHwMx0ojUcjt0HYkqZ9oBCfipd/5mD7Md58m2/dq7uPuZU/0T3Gb1m66KS9jn+I+14Q==",
       "dev": true
     },
+    "wait-promise": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/wait-promise/-/wait-promise-0.4.1.tgz",
+      "integrity": "sha1-Hq2PgtKRolAQwLHu+NRxEyPMTas=",
+      "dev": true
+    },
     "walk": {
       "version": "2.3.9",
       "resolved": "https://registry.npmjs.org/walk/-/walk-2.3.9.tgz",
diff --git a/package.json b/package.json
index fa1c9c91d4..b4fdd18536 100644
--- a/package.json
+++ b/package.json
@@ -133,6 +133,7 @@
     "eslint-plugin-react": "^7.6.1",
     "eslint-plugin-standard": "^3.0.1",
     "fetch-ponyfill": "^6.0.0",
+    "fs-readfile-promise": "^3.0.1",
     "icedfrisby": "2.0.0-alpha.2",
     "icedfrisby-nock": "^1.0.0",
     "is-png": "^1.1.0",
@@ -163,7 +164,9 @@
     "sinon": "^6.0.0",
     "sinon-chai": "^3.0.0",
     "snap-shot-it": "^5.0.1",
-    "url": "^0.11.0"
+    "tmp": "0.0.33",
+    "url": "^0.11.0",
+    "wait-promise": "^0.4.1"
   },
   "engines": {
     "node": ">= 8.x",
diff --git a/server.js b/server.js
index 238ab78f96..918ffccfe4 100644
--- a/server.js
+++ b/server.js
@@ -132,10 +132,12 @@ function reset() {
   clearRegularUpdateCache();
 }
 
-function stop(callback) {
-  githubConstellation.stop();
+async function stop() {
+  await githubConstellation.stop();
   analytics.cancelAutosaving();
-  camp.close(callback);
+  return new Promise(resolve => {
+    camp.close(resolve);
+  });
 }
 
 module.exports = {
diff --git a/services/github/github-constellation.js b/services/github/github-constellation.js
index 7ea11b7bbd..e5bec5f26f 100644
--- a/services/github/github-constellation.js
+++ b/services/github/github-constellation.js
@@ -4,6 +4,7 @@ const path = require('path')
 const githubAuth = require('../../lib/github-auth')
 const serverSecrets = require('../../lib/server-secrets')
 const log = require('../../lib/log')
+const TokenPersistence = require('../../lib/token-persistence')
 const GithubApiProvider = require('./github-api-provider')
 const { setRoutes: setAdminRoutes } = require('./auth/admin')
 
@@ -13,10 +14,12 @@ class GithubConstellation {
   constructor(config) {
     this._debugEnabled = config.service.debug.enabled
     this._debugIntervalSeconds = config.service.debug.intervalSeconds
-    this._userTokensPath = path.resolve(
+
+    const userTokensPath = path.resolve(
       config.persistence.dir,
       'github-user-tokens.json'
     )
+    this.persistence = new TokenPersistence({ path: userTokensPath })
 
     const baseUrl = process.env.GITHUB_URL || 'https://api.github.com'
     this.apiProvider = new GithubApiProvider({ baseUrl })
@@ -33,8 +36,12 @@ class GithubConstellation {
   async initialize(server) {
     this.scheduleDebugLogging()
 
-    githubAuth.scheduleAutosaving(this._userTokensPath)
-    // TODO Catch errors and send them to Sentry.
+    try {
+      await this.persistence.initialize()
+    } catch (e) {
+      // TODO Send to sentry.
+      console.error(e)
+    }
 
     setAdminRoutes(server)
 
@@ -43,12 +50,14 @@ class GithubConstellation {
     }
   }
 
-  stop() {
-    githubAuth.cancelAutosaving()
+  async stop() {
     if (this.debugInterval) {
       clearInterval(this.debugInterval)
       this.debugInterval = undefined
     }
+
+    await this.persistence.stop()
+    this.persistence = undefined
   }
 }
 
-- 
GitLab