Преглед изворни кода

prettier: convert test/unit decaffeinated files to Prettier format

mserranom пре 6 година
родитељ
комит
a62d8186e9

+ 275 - 243
services/clsi/test/unit/js/CompileControllerTests.js

@@ -9,267 +9,299 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/CompileController');
-const tk = require("timekeeper");
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/CompileController'
+)
+const tk = require('timekeeper')
 
-describe("CompileController", function() {
-	beforeEach(function() {
-		this.CompileController = SandboxedModule.require(modulePath, { requires: {
-			"./CompileManager": (this.CompileManager = {}),
-			"./RequestParser": (this.RequestParser = {}),
-			"settings-sharelatex": (this.Settings = {
-				apis: {
-					clsi: {
-						url: "http://clsi.example.com"
-					}
-				}
-			}),
-			"./ProjectPersistenceManager": (this.ProjectPersistenceManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), err:sinon.stub(), warn: sinon.stub()})
-		}
-	});
-		this.Settings.externalUrl = "http://www.example.com";
-		this.req = {};
-		this.res = {};
-		return this.next = sinon.stub();
-	});
+describe('CompileController', function() {
+  beforeEach(function() {
+    this.CompileController = SandboxedModule.require(modulePath, {
+      requires: {
+        './CompileManager': (this.CompileManager = {}),
+        './RequestParser': (this.RequestParser = {}),
+        'settings-sharelatex': (this.Settings = {
+          apis: {
+            clsi: {
+              url: 'http://clsi.example.com'
+            }
+          }
+        }),
+        './ProjectPersistenceManager': (this.ProjectPersistenceManager = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          err: sinon.stub(),
+          warn: sinon.stub()
+        })
+      }
+    })
+    this.Settings.externalUrl = 'http://www.example.com'
+    this.req = {}
+    this.res = {}
+    return (this.next = sinon.stub())
+  })
 
-	describe("compile", function() {
-		beforeEach(function() {
-			this.req.body = {
-				compile: "mock-body"
-			};
-			this.req.params =
-				{project_id: (this.project_id = "project-id-123")};
-			this.request = {
-				compile: "mock-parsed-request"
-			};
-			this.request_with_project_id = {
-				compile: this.request.compile,
-				project_id: this.project_id
-			};
-			this.output_files = [{
-				path: "output.pdf",
-				type: "pdf",
-				build: 1234
-			}, {
-				path: "output.log",
-				type: "log",
-				build: 1234
-			}];
-			this.RequestParser.parse = sinon.stub().callsArgWith(1, null, this.request);
-			this.ProjectPersistenceManager.markProjectAsJustAccessed = sinon.stub().callsArg(1);
-			this.res.status = sinon.stub().returnsThis();
-			return this.res.send = sinon.stub();
-		});
+  describe('compile', function() {
+    beforeEach(function() {
+      this.req.body = {
+        compile: 'mock-body'
+      }
+      this.req.params = { project_id: (this.project_id = 'project-id-123') }
+      this.request = {
+        compile: 'mock-parsed-request'
+      }
+      this.request_with_project_id = {
+        compile: this.request.compile,
+        project_id: this.project_id
+      }
+      this.output_files = [
+        {
+          path: 'output.pdf',
+          type: 'pdf',
+          build: 1234
+        },
+        {
+          path: 'output.log',
+          type: 'log',
+          build: 1234
+        }
+      ]
+      this.RequestParser.parse = sinon
+        .stub()
+        .callsArgWith(1, null, this.request)
+      this.ProjectPersistenceManager.markProjectAsJustAccessed = sinon
+        .stub()
+        .callsArg(1)
+      this.res.status = sinon.stub().returnsThis()
+      return (this.res.send = sinon.stub())
+    })
 
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.CompileManager.doCompileWithLock = sinon.stub().callsArgWith(1, null, this.output_files);
-				return this.CompileController.compile(this.req, this.res);
-			});
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.CompileManager.doCompileWithLock = sinon
+          .stub()
+          .callsArgWith(1, null, this.output_files)
+        return this.CompileController.compile(this.req, this.res)
+      })
 
-			it("should parse the request", function() {
-				return this.RequestParser.parse
-					.calledWith(this.req.body)
-					.should.equal(true);
-			});
+      it('should parse the request', function() {
+        return this.RequestParser.parse
+          .calledWith(this.req.body)
+          .should.equal(true)
+      })
 
-			it("should run the compile for the specified project", function() {
-				return this.CompileManager.doCompileWithLock
-					.calledWith(this.request_with_project_id)
-					.should.equal(true);
-			});
+      it('should run the compile for the specified project', function() {
+        return this.CompileManager.doCompileWithLock
+          .calledWith(this.request_with_project_id)
+          .should.equal(true)
+      })
 
-			it("should mark the project as accessed", function() {
-				return this.ProjectPersistenceManager.markProjectAsJustAccessed
-					.calledWith(this.project_id)
-					.should.equal(true);
-			});
+      it('should mark the project as accessed', function() {
+        return this.ProjectPersistenceManager.markProjectAsJustAccessed
+          .calledWith(this.project_id)
+          .should.equal(true)
+      })
 
-			return it("should return the JSON response", function() {
-				this.res.status.calledWith(200).should.equal(true);
-				return this.res.send
-					.calledWith({
-						compile: {
-							status: "success",
-							error: null,
-							outputFiles: this.output_files.map(file => {
-								return {
-									url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
-									path: file.path,
-									type: file.type,
-									build: file.build
-								};
-						})
-						}
-					})
-					.should.equal(true);
-			});
-		});
-			
-		describe("with an error", function() {
-			beforeEach(function() {
-				this.CompileManager.doCompileWithLock = sinon.stub().callsArgWith(1, new Error(this.message = "error message"), null);
-				return this.CompileController.compile(this.req, this.res);
-			});
-		
-			return it("should return the JSON response with the error", function() {
-				this.res.status.calledWith(500).should.equal(true);
-				return this.res.send
-					.calledWith({
-						compile: {
-							status: "error",
-							error:  this.message,
-							outputFiles: []
-						}
-					})
-					.should.equal(true);
-			});
-		});
+      return it('should return the JSON response', function() {
+        this.res.status.calledWith(200).should.equal(true)
+        return this.res.send
+          .calledWith({
+            compile: {
+              status: 'success',
+              error: null,
+              outputFiles: this.output_files.map(file => {
+                return {
+                  url: `${this.Settings.apis.clsi.url}/project/${this.project_id}/build/${file.build}/output/${file.path}`,
+                  path: file.path,
+                  type: file.type,
+                  build: file.build
+                }
+              })
+            }
+          })
+          .should.equal(true)
+      })
+    })
 
-		describe("when the request times out", function() {
-			beforeEach(function() {
-				this.error = new Error(this.message = "container timed out");
-				this.error.timedout = true;
-				this.CompileManager.doCompileWithLock = sinon.stub().callsArgWith(1, this.error, null);
-				return this.CompileController.compile(this.req, this.res);
-			});
-		
-			return it("should return the JSON response with the timeout status", function() {
-				this.res.status.calledWith(200).should.equal(true);
-				return this.res.send
-					.calledWith({
-						compile: {
-							status: "timedout",
-							error: this.message,
-							outputFiles: []
-						}
-					})
-					.should.equal(true);
-			});
-		});
+    describe('with an error', function() {
+      beforeEach(function() {
+        this.CompileManager.doCompileWithLock = sinon
+          .stub()
+          .callsArgWith(1, new Error((this.message = 'error message')), null)
+        return this.CompileController.compile(this.req, this.res)
+      })
 
-		return describe("when the request returns no output files", function() {
-			beforeEach(function() {
-				this.CompileManager.doCompileWithLock = sinon.stub().callsArgWith(1, null, []);
-				return this.CompileController.compile(this.req, this.res);
-			});
-		
-			return it("should return the JSON response with the failure status", function() {
-				this.res.status.calledWith(200).should.equal(true);
-				return this.res.send
-					.calledWith({
-						compile: {
-							error: null,
-							status: "failure",
-							outputFiles: []
-						}
-					})
-					.should.equal(true);
-			});
-		});
-	});
+      return it('should return the JSON response with the error', function() {
+        this.res.status.calledWith(500).should.equal(true)
+        return this.res.send
+          .calledWith({
+            compile: {
+              status: 'error',
+              error: this.message,
+              outputFiles: []
+            }
+          })
+          .should.equal(true)
+      })
+    })
 
-	describe("syncFromCode", function() {
-		beforeEach(function() {
-			this.file = "main.tex";
-			this.line = 42;
-			this.column = 5;
-			this.project_id = "mock-project-id";
-			this.req.params =
-				{project_id: this.project_id};
-			this.req.query = {
-				file: this.file,
-				line: this.line.toString(),
-				column: this.column.toString()
-			};
-			this.res.json = sinon.stub();
+    describe('when the request times out', function() {
+      beforeEach(function() {
+        this.error = new Error((this.message = 'container timed out'))
+        this.error.timedout = true
+        this.CompileManager.doCompileWithLock = sinon
+          .stub()
+          .callsArgWith(1, this.error, null)
+        return this.CompileController.compile(this.req, this.res)
+      })
 
-			this.CompileManager.syncFromCode = sinon.stub().callsArgWith(5, null, (this.pdfPositions = ["mock-positions"]));
-			return this.CompileController.syncFromCode(this.req, this.res, this.next);
-		});
+      return it('should return the JSON response with the timeout status', function() {
+        this.res.status.calledWith(200).should.equal(true)
+        return this.res.send
+          .calledWith({
+            compile: {
+              status: 'timedout',
+              error: this.message,
+              outputFiles: []
+            }
+          })
+          .should.equal(true)
+      })
+    })
 
-		it("should find the corresponding location in the PDF", function() {
-			return this.CompileManager.syncFromCode
-				.calledWith(this.project_id, undefined, this.file, this.line, this.column)
-				.should.equal(true);
-		});
+    return describe('when the request returns no output files', function() {
+      beforeEach(function() {
+        this.CompileManager.doCompileWithLock = sinon
+          .stub()
+          .callsArgWith(1, null, [])
+        return this.CompileController.compile(this.req, this.res)
+      })
 
-		return it("should return the positions", function() {
-			return this.res.json
-				.calledWith({
-					pdf: this.pdfPositions
-				})
-				.should.equal(true);
-		});
-	});
+      return it('should return the JSON response with the failure status', function() {
+        this.res.status.calledWith(200).should.equal(true)
+        return this.res.send
+          .calledWith({
+            compile: {
+              error: null,
+              status: 'failure',
+              outputFiles: []
+            }
+          })
+          .should.equal(true)
+      })
+    })
+  })
 
-	describe("syncFromPdf", function() {
-		beforeEach(function() {
-			this.page = 5;
-			this.h = 100.23;
-			this.v = 45.67;
-			this.project_id = "mock-project-id";
-			this.req.params =
-				{project_id: this.project_id};
-			this.req.query = {
-				page: this.page.toString(),
-				h: this.h.toString(),
-				v: this.v.toString()
-			};
-			this.res.json = sinon.stub();
+  describe('syncFromCode', function() {
+    beforeEach(function() {
+      this.file = 'main.tex'
+      this.line = 42
+      this.column = 5
+      this.project_id = 'mock-project-id'
+      this.req.params = { project_id: this.project_id }
+      this.req.query = {
+        file: this.file,
+        line: this.line.toString(),
+        column: this.column.toString()
+      }
+      this.res.json = sinon.stub()
 
-			this.CompileManager.syncFromPdf = sinon.stub().callsArgWith(5, null, (this.codePositions = ["mock-positions"]));
-			return this.CompileController.syncFromPdf(this.req, this.res, this.next);
-		});
+      this.CompileManager.syncFromCode = sinon
+        .stub()
+        .callsArgWith(5, null, (this.pdfPositions = ['mock-positions']))
+      return this.CompileController.syncFromCode(this.req, this.res, this.next)
+    })
 
-		it("should find the corresponding location in the code", function() {
-			return this.CompileManager.syncFromPdf
-				.calledWith(this.project_id, undefined, this.page, this.h, this.v)
-				.should.equal(true);
-		});
+    it('should find the corresponding location in the PDF', function() {
+      return this.CompileManager.syncFromCode
+        .calledWith(
+          this.project_id,
+          undefined,
+          this.file,
+          this.line,
+          this.column
+        )
+        .should.equal(true)
+    })
 
-		return it("should return the positions", function() {
-			return this.res.json
-				.calledWith({
-					code: this.codePositions
-				})
-				.should.equal(true);
-		});
-	});
+    return it('should return the positions', function() {
+      return this.res.json
+        .calledWith({
+          pdf: this.pdfPositions
+        })
+        .should.equal(true)
+    })
+  })
 
-	return describe("wordcount", function() {
-		beforeEach(function() {
-			this.file = "main.tex";
-			this.project_id = "mock-project-id";
-			this.req.params =
-				{project_id: this.project_id};
-			this.req.query = {
-				file: this.file,
-				image: (this.image = "example.com/image")
-			};
-			this.res.json = sinon.stub();
+  describe('syncFromPdf', function() {
+    beforeEach(function() {
+      this.page = 5
+      this.h = 100.23
+      this.v = 45.67
+      this.project_id = 'mock-project-id'
+      this.req.params = { project_id: this.project_id }
+      this.req.query = {
+        page: this.page.toString(),
+        h: this.h.toString(),
+        v: this.v.toString()
+      }
+      this.res.json = sinon.stub()
 
-			this.CompileManager.wordcount = sinon.stub().callsArgWith(4, null, (this.texcount = ["mock-texcount"]));
-			return this.CompileController.wordcount(this.req, this.res, this.next);
-		});
+      this.CompileManager.syncFromPdf = sinon
+        .stub()
+        .callsArgWith(5, null, (this.codePositions = ['mock-positions']))
+      return this.CompileController.syncFromPdf(this.req, this.res, this.next)
+    })
 
-		it("should return the word count of a file", function() {
-			return this.CompileManager.wordcount
-				.calledWith(this.project_id, undefined, this.file, this.image)
-				.should.equal(true);
-		});
+    it('should find the corresponding location in the code', function() {
+      return this.CompileManager.syncFromPdf
+        .calledWith(this.project_id, undefined, this.page, this.h, this.v)
+        .should.equal(true)
+    })
 
-		return it("should return the texcount info", function() {
-			return this.res.json
-				.calledWith({
-					texcount: this.texcount
-				})
-				.should.equal(true);
-		});
-	});
-});
+    return it('should return the positions', function() {
+      return this.res.json
+        .calledWith({
+          code: this.codePositions
+        })
+        .should.equal(true)
+    })
+  })
+
+  return describe('wordcount', function() {
+    beforeEach(function() {
+      this.file = 'main.tex'
+      this.project_id = 'mock-project-id'
+      this.req.params = { project_id: this.project_id }
+      this.req.query = {
+        file: this.file,
+        image: (this.image = 'example.com/image')
+      }
+      this.res.json = sinon.stub()
+
+      this.CompileManager.wordcount = sinon
+        .stub()
+        .callsArgWith(4, null, (this.texcount = ['mock-texcount']))
+      return this.CompileController.wordcount(this.req, this.res, this.next)
+    })
+
+    it('should return the word count of a file', function() {
+      return this.CompileManager.wordcount
+        .calledWith(this.project_id, undefined, this.file, this.image)
+        .should.equal(true)
+    })
+
+    return it('should return the texcount info', function() {
+      return this.res.json
+        .calledWith({
+          texcount: this.texcount
+        })
+        .should.equal(true)
+    })
+  })
+})

+ 536 - 420
services/clsi/test/unit/js/CompileManagerTests.js

@@ -13,423 +13,539 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/CompileManager');
-const tk = require("timekeeper");
-const { EventEmitter } = require("events");
-const Path = require("path");
-
-describe("CompileManager", function() {
-	beforeEach(function() {
-		this.CompileManager = SandboxedModule.require(modulePath, { requires: {
-			"./LatexRunner": (this.LatexRunner = {}),
-			"./ResourceWriter": (this.ResourceWriter = {}),
-			"./OutputFileFinder": (this.OutputFileFinder = {}),
-			"./OutputCacheManager": (this.OutputCacheManager = {}),
-			"settings-sharelatex": (this.Settings = {
-				path: {
-					compilesDir: "/compiles/dir"
-				},
-				synctexBaseDir() { return "/compile"; },
-				clsi: {
-					docker: {
-						image: "SOMEIMAGE"
-					}
-				}
-			}),
-
-			"logger-sharelatex": (this.logger = { log: sinon.stub() , info() {}}),
-			"child_process": (this.child_process = {}),
-			"./CommandRunner": (this.CommandRunner = {}),
-			"./DraftModeManager": (this.DraftModeManager = {}),
-			"./TikzManager": (this.TikzManager = {}),
-			"./LockManager": (this.LockManager = {}),
-			"fs": (this.fs = {}),
-			"fs-extra": (this.fse = { ensureDir: sinon.stub().callsArg(1) })
-		}
-	});
-		this.callback = sinon.stub();
-		this.project_id = "project-id-123";
-		return this.user_id = "1234";
-	});
-	describe("doCompileWithLock", function() {
-		beforeEach(function() {
-			this.request = {
-				resources: (this.resources = "mock-resources"),
-				project_id: this.project_id,
-				user_id: this.user_id
-			};
-			this.output_files = ["foo", "bar"];
-			this.Settings.compileDir = "compiles";
-			this.compileDir = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`;
-			this.CompileManager.doCompile = sinon.stub().callsArgWith(1, null, this.output_files);
-			return this.LockManager.runWithLock = (lockFile, runner, callback) =>
-				runner((err, ...result) => callback(err, ...Array.from(result)))
-			;
-		});
-
-		describe("when the project is not locked", function() {
-			beforeEach(function() {
-				return this.CompileManager.doCompileWithLock(this.request, this.callback);
-			});
-
-			it("should ensure that the compile directory exists", function() {
-				return this.fse.ensureDir.calledWith(this.compileDir)
-				.should.equal(true);
-			});
-
-			it("should call doCompile with the request", function() {
-				return this.CompileManager.doCompile
-				.calledWith(this.request)
-				.should.equal(true);
-			});
-
-			return it("should call the callback with the output files", function() {
-				return this.callback.calledWithExactly(null, this.output_files)
-				.should.equal(true);
-			});
-		});
-
-		return describe("when the project is locked", function() {
-			beforeEach(function() {
-				this.error = new Error("locked");
-				this.LockManager.runWithLock = (lockFile, runner, callback) => {
-					return callback(this.error);
-				};
-				return this.CompileManager.doCompileWithLock(this.request, this.callback);
-			});
-
-			it("should ensure that the compile directory exists", function() {
-				return this.fse.ensureDir.calledWith(this.compileDir)
-				.should.equal(true);
-			});
-
-			it("should not call doCompile with the request", function() {
-				return this.CompileManager.doCompile
-				.called.should.equal(false);
-			});
-
-			return it("should call the callback with the error", function() {
-				return this.callback.calledWithExactly(this.error)
-				.should.equal(true);
-			});
-		});
-	});
-
-	describe("doCompile", function() {
-		beforeEach(function() {
-			this.output_files = [{
-				path: "output.log",
-				type: "log"
-			}, {
-				path: "output.pdf",
-				type: "pdf"
-			}];
-			this.build_files = [{
-				path: "output.log",
-				type: "log",
-				build: 1234
-			}, {
-				path: "output.pdf",
-				type: "pdf",
-				build: 1234
-			}];
-			this.request = {
-				resources: (this.resources = "mock-resources"),
-				rootResourcePath: (this.rootResourcePath = "main.tex"),
-				project_id: this.project_id,
-				user_id: this.user_id,
-				compiler: (this.compiler = "pdflatex"),
-				timeout: (this.timeout = 42000),
-				imageName: (this.image = "example.com/image"),
-				flags: (this.flags = ["-file-line-error"])
-			};
-			this.env = {};
-			this.Settings.compileDir = "compiles";
-			this.compileDir = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`;
-			this.ResourceWriter.syncResourcesToDisk = sinon.stub().callsArgWith(2, null, this.resources);
-			this.LatexRunner.runLatex = sinon.stub().callsArg(2);
-			this.OutputFileFinder.findOutputFiles = sinon.stub().callsArgWith(2, null, this.output_files);
-			this.OutputCacheManager.saveOutputFiles = sinon.stub().callsArgWith(2, null, this.build_files);
-			this.DraftModeManager.injectDraftMode = sinon.stub().callsArg(1);
-			return this.TikzManager.checkMainFile = sinon.stub().callsArg(3, false);
-		});
-
-		describe("normally", function() {
-			beforeEach(function() {
-				return this.CompileManager.doCompile(this.request, this.callback);
-			});
-
-			it("should write the resources to disk", function() {
-				return this.ResourceWriter.syncResourcesToDisk
-					.calledWith(this.request, this.compileDir)
-					.should.equal(true);
-			});
-
-			it("should run LaTeX", function() {
-				return this.LatexRunner.runLatex
-					.calledWith(`${this.project_id}-${this.user_id}`, {
-						directory: this.compileDir,
-						mainFile:  this.rootResourcePath,
-						compiler:  this.compiler,
-						timeout:   this.timeout,
-						image:     this.image,
-						flags:     this.flags,
-						environment: this.env
-					})
-					.should.equal(true);
-			});
-
-			it("should find the output files", function() {
-				return this.OutputFileFinder.findOutputFiles
-					.calledWith(this.resources, this.compileDir)
-					.should.equal(true);
-			});
-
-			it("should return the output files", function() {
-				return this.callback.calledWith(null, this.build_files).should.equal(true);
-			});
-
-			return it("should not inject draft mode by default", function() {
-				return this.DraftModeManager.injectDraftMode.called.should.equal(false);
-			});
-		});
-
-		describe("with draft mode", function() {
-			beforeEach(function() {
-				this.request.draft = true;
-				return this.CompileManager.doCompile(this.request, this.callback);
-			});
-
-			return it("should inject the draft mode header", function() {
-				return this.DraftModeManager.injectDraftMode
-					.calledWith(this.compileDir + "/" + this.rootResourcePath)
-					.should.equal(true);
-			});
-		});
-
-		describe("with a check option", function() {
-			beforeEach(function() {
-				this.request.check = "error";
-				return this.CompileManager.doCompile(this.request, this.callback);
-			});
-
-			return it("should run chktex", function() {
-				return this.LatexRunner.runLatex
-					.calledWith(`${this.project_id}-${this.user_id}`, {
-						directory: this.compileDir,
-						mainFile:  this.rootResourcePath,
-						compiler:  this.compiler,
-						timeout:   this.timeout,
-						image:     this.image,
-						flags:     this.flags,
-						environment: {'CHKTEX_OPTIONS': '-nall -e9 -e10 -w15 -w16', 'CHKTEX_EXIT_ON_ERROR':1, 'CHKTEX_ULIMIT_OPTIONS': '-t 5 -v 64000'}
-					})
-					.should.equal(true);
-			});
-		});
-
-		return describe("with a knitr file and check options", function() {
-			beforeEach(function() {
-				this.request.rootResourcePath = "main.Rtex";
-				this.request.check = "error";
-				return this.CompileManager.doCompile(this.request, this.callback);
-			});
-
-			return it("should not run chktex", function() {
-				return this.LatexRunner.runLatex
-					.calledWith(`${this.project_id}-${this.user_id}`, {
-						directory: this.compileDir,
-						mainFile:  "main.Rtex",
-						compiler:  this.compiler,
-						timeout:   this.timeout,
-						image:     this.image,
-						flags:     this.flags,
-						environment: this.env
-					})
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("clearProject", function() {
-		describe("succesfully", function() {
-			beforeEach(function() {
-				this.Settings.compileDir = "compiles";
-				this.fs.lstat = sinon.stub().callsArgWith(1, null,{isDirectory(){ return true; }});
-				this.proc = new EventEmitter();
-				this.proc.stdout = new EventEmitter();
-				this.proc.stderr = new EventEmitter();
-				this.child_process.spawn = sinon.stub().returns(this.proc);
-				this.CompileManager.clearProject(this.project_id, this.user_id, this.callback);
-				return this.proc.emit("close", 0);
-			});
-
-			it("should remove the project directory", function() {
-				return this.child_process.spawn
-					.calledWith("rm", ["-r", `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`])
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("with a non-success status code", function() {
-			beforeEach(function() {
-				this.Settings.compileDir = "compiles";
-				this.fs.lstat = sinon.stub().callsArgWith(1, null,{isDirectory(){ return true; }});
-				this.proc = new EventEmitter();
-				this.proc.stdout = new EventEmitter();
-				this.proc.stderr = new EventEmitter();
-				this.child_process.spawn = sinon.stub().returns(this.proc);
-				this.CompileManager.clearProject(this.project_id, this.user_id, this.callback);
-				this.proc.stderr.emit("data", (this.error = "oops"));
-				return this.proc.emit("close", 1);
-			});
-
-			it("should remove the project directory", function() {
-				return this.child_process.spawn
-					.calledWith("rm", ["-r", `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`])
-					.should.equal(true);
-			});
-
-			return it("should call the callback with an error from the stderr", function() {
-				this.callback
-					.calledWith(new Error())
-					.should.equal(true);
-
-				return this.callback.args[0][0].message.should.equal(`rm -r ${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id} failed: ${this.error}`);
-			});
-		});
-	});
-
-	describe("syncing", function() {
-		beforeEach(function() {
-			this.page = 1;
-			this.h = 42.23;
-			this.v = 87.56;
-			this.width = 100.01;
-			this.height = 234.56;
-			this.line = 5;
-			this.column = 3;
-			this.file_name = "main.tex";
-			this.child_process.execFile = sinon.stub();
-			return this.Settings.path.synctexBaseDir = project_id => `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`;
-		});
-
-		describe("syncFromCode", function() {
-			beforeEach(function() {
-				this.fs.stat = sinon.stub().callsArgWith(1, null,{isFile(){ return true; }});
-				this.stdout = `NODE\t${this.page}\t${this.h}\t${this.v}\t${this.width}\t${this.height}\n`;
-				this.CommandRunner.run = sinon.stub().callsArgWith(6, null, {stdout:this.stdout});
-				return this.CompileManager.syncFromCode(this.project_id, this.user_id, this.file_name, this.line, this.column, this.callback);
-			});
-
-			it("should execute the synctex binary", function() {
-				const bin_path = Path.resolve(__dirname + "/../../../bin/synctex");
-				const synctex_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/output.pdf`;
-				const file_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/${this.file_name}`;
-				return this.CommandRunner.run
-					.calledWith(
-						`${this.project_id}-${this.user_id}`,
-						['/opt/synctex', 'code', synctex_path, file_path, this.line, this.column],
-						`${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`,
-						this.Settings.clsi.docker.image,
-						60000,
-						{}
-						).should.equal(true);
-			});
-
-			return it("should call the callback with the parsed output", function() {
-				return this.callback
-					.calledWith(null, [{
-						page: this.page,
-						h: this.h,
-						v: this.v,
-						height: this.height,
-						width: this.width
-					}])
-					.should.equal(true);
-			});
-		});
-
-		return describe("syncFromPdf", function() {
-			beforeEach(function() {
-				this.fs.stat = sinon.stub().callsArgWith(1, null,{isFile(){ return true; }});
-				this.stdout = `NODE\t${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/${this.file_name}\t${this.line}\t${this.column}\n`;
-				this.CommandRunner.run = sinon.stub().callsArgWith(6, null, {stdout:this.stdout});
-				return this.CompileManager.syncFromPdf(this.project_id, this.user_id, this.page, this.h, this.v, this.callback);
-			});
-
-			it("should execute the synctex binary", function() {
-				const bin_path = Path.resolve(__dirname + "/../../../bin/synctex");
-				const synctex_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/output.pdf`;
-				return this.CommandRunner.run
-					.calledWith(
-						`${this.project_id}-${this.user_id}`,
-						['/opt/synctex', "pdf", synctex_path, this.page, this.h, this.v],
-						`${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`,
-						this.Settings.clsi.docker.image,
-						60000,
-						{}).should.equal(true);
-			});
-
-			return it("should call the callback with the parsed output", function() {
-				return this.callback
-					.calledWith(null, [{
-						file: this.file_name,
-						line: this.line,
-						column: this.column
-					}])
-					.should.equal(true);
-			});
-		});
-	});
-
-	return describe("wordcount", function() {
-		beforeEach(function() {
-			this.CommandRunner.run = sinon.stub().callsArg(6);
-			this.fs.readFile = sinon.stub().callsArgWith(2, null, (this.stdout = "Encoding: ascii\nWords in text: 2"));
-			this.callback  = sinon.stub();
-
-			this.project_id;
-			this.timeout = 60 * 1000;
-			this.file_name = "main.tex";
-			this.Settings.path.compilesDir = "/local/compile/directory";
-			this.image = "example.com/image";
-
-			return this.CompileManager.wordcount(this.project_id, this.user_id, this.file_name, this.image, this.callback);
-		});
-
-		it("should run the texcount command", function() {
-			this.directory = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`;
-			this.file_path = `$COMPILE_DIR/${this.file_name}`;
-			this.command =[ "texcount", "-nocol", "-inc", this.file_path, `-out=${this.file_path}.wc`];
-
-			return this.CommandRunner.run
-				.calledWith(`${this.project_id}-${this.user_id}`, this.command, this.directory, this.image, this.timeout, {})
-				.should.equal(true);
-		});
-
-		return it("should call the callback with the parsed output", function() {
-			return this.callback
-				.calledWith(null, {
-					encode: "ascii",
-					textWords: 2,
-					headWords: 0,
-					outside: 0,
-					headers: 0,
-					elements: 0,
-					mathInline: 0,
-					mathDisplay: 0,
-					errors: 0,
-					messages: ""
-				})
-				.should.equal(true);
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/CompileManager'
+)
+const tk = require('timekeeper')
+const { EventEmitter } = require('events')
+const Path = require('path')
+
+describe('CompileManager', function() {
+  beforeEach(function() {
+    this.CompileManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './LatexRunner': (this.LatexRunner = {}),
+        './ResourceWriter': (this.ResourceWriter = {}),
+        './OutputFileFinder': (this.OutputFileFinder = {}),
+        './OutputCacheManager': (this.OutputCacheManager = {}),
+        'settings-sharelatex': (this.Settings = {
+          path: {
+            compilesDir: '/compiles/dir'
+          },
+          synctexBaseDir() {
+            return '/compile'
+          },
+          clsi: {
+            docker: {
+              image: 'SOMEIMAGE'
+            }
+          }
+        }),
+
+        'logger-sharelatex': (this.logger = { log: sinon.stub(), info() {} }),
+        child_process: (this.child_process = {}),
+        './CommandRunner': (this.CommandRunner = {}),
+        './DraftModeManager': (this.DraftModeManager = {}),
+        './TikzManager': (this.TikzManager = {}),
+        './LockManager': (this.LockManager = {}),
+        fs: (this.fs = {}),
+        'fs-extra': (this.fse = { ensureDir: sinon.stub().callsArg(1) })
+      }
+    })
+    this.callback = sinon.stub()
+    this.project_id = 'project-id-123'
+    return (this.user_id = '1234')
+  })
+  describe('doCompileWithLock', function() {
+    beforeEach(function() {
+      this.request = {
+        resources: (this.resources = 'mock-resources'),
+        project_id: this.project_id,
+        user_id: this.user_id
+      }
+      this.output_files = ['foo', 'bar']
+      this.Settings.compileDir = 'compiles'
+      this.compileDir = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`
+      this.CompileManager.doCompile = sinon
+        .stub()
+        .callsArgWith(1, null, this.output_files)
+      return (this.LockManager.runWithLock = (lockFile, runner, callback) =>
+        runner((err, ...result) => callback(err, ...Array.from(result))))
+    })
+
+    describe('when the project is not locked', function() {
+      beforeEach(function() {
+        return this.CompileManager.doCompileWithLock(
+          this.request,
+          this.callback
+        )
+      })
+
+      it('should ensure that the compile directory exists', function() {
+        return this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
+      })
+
+      it('should call doCompile with the request', function() {
+        return this.CompileManager.doCompile
+          .calledWith(this.request)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the output files', function() {
+        return this.callback
+          .calledWithExactly(null, this.output_files)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the project is locked', function() {
+      beforeEach(function() {
+        this.error = new Error('locked')
+        this.LockManager.runWithLock = (lockFile, runner, callback) => {
+          return callback(this.error)
+        }
+        return this.CompileManager.doCompileWithLock(
+          this.request,
+          this.callback
+        )
+      })
+
+      it('should ensure that the compile directory exists', function() {
+        return this.fse.ensureDir.calledWith(this.compileDir).should.equal(true)
+      })
+
+      it('should not call doCompile with the request', function() {
+        return this.CompileManager.doCompile.called.should.equal(false)
+      })
+
+      return it('should call the callback with the error', function() {
+        return this.callback.calledWithExactly(this.error).should.equal(true)
+      })
+    })
+  })
+
+  describe('doCompile', function() {
+    beforeEach(function() {
+      this.output_files = [
+        {
+          path: 'output.log',
+          type: 'log'
+        },
+        {
+          path: 'output.pdf',
+          type: 'pdf'
+        }
+      ]
+      this.build_files = [
+        {
+          path: 'output.log',
+          type: 'log',
+          build: 1234
+        },
+        {
+          path: 'output.pdf',
+          type: 'pdf',
+          build: 1234
+        }
+      ]
+      this.request = {
+        resources: (this.resources = 'mock-resources'),
+        rootResourcePath: (this.rootResourcePath = 'main.tex'),
+        project_id: this.project_id,
+        user_id: this.user_id,
+        compiler: (this.compiler = 'pdflatex'),
+        timeout: (this.timeout = 42000),
+        imageName: (this.image = 'example.com/image'),
+        flags: (this.flags = ['-file-line-error'])
+      }
+      this.env = {}
+      this.Settings.compileDir = 'compiles'
+      this.compileDir = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`
+      this.ResourceWriter.syncResourcesToDisk = sinon
+        .stub()
+        .callsArgWith(2, null, this.resources)
+      this.LatexRunner.runLatex = sinon.stub().callsArg(2)
+      this.OutputFileFinder.findOutputFiles = sinon
+        .stub()
+        .callsArgWith(2, null, this.output_files)
+      this.OutputCacheManager.saveOutputFiles = sinon
+        .stub()
+        .callsArgWith(2, null, this.build_files)
+      this.DraftModeManager.injectDraftMode = sinon.stub().callsArg(1)
+      return (this.TikzManager.checkMainFile = sinon.stub().callsArg(3, false))
+    })
+
+    describe('normally', function() {
+      beforeEach(function() {
+        return this.CompileManager.doCompile(this.request, this.callback)
+      })
+
+      it('should write the resources to disk', function() {
+        return this.ResourceWriter.syncResourcesToDisk
+          .calledWith(this.request, this.compileDir)
+          .should.equal(true)
+      })
+
+      it('should run LaTeX', function() {
+        return this.LatexRunner.runLatex
+          .calledWith(`${this.project_id}-${this.user_id}`, {
+            directory: this.compileDir,
+            mainFile: this.rootResourcePath,
+            compiler: this.compiler,
+            timeout: this.timeout,
+            image: this.image,
+            flags: this.flags,
+            environment: this.env
+          })
+          .should.equal(true)
+      })
+
+      it('should find the output files', function() {
+        return this.OutputFileFinder.findOutputFiles
+          .calledWith(this.resources, this.compileDir)
+          .should.equal(true)
+      })
+
+      it('should return the output files', function() {
+        return this.callback
+          .calledWith(null, this.build_files)
+          .should.equal(true)
+      })
+
+      return it('should not inject draft mode by default', function() {
+        return this.DraftModeManager.injectDraftMode.called.should.equal(false)
+      })
+    })
+
+    describe('with draft mode', function() {
+      beforeEach(function() {
+        this.request.draft = true
+        return this.CompileManager.doCompile(this.request, this.callback)
+      })
+
+      return it('should inject the draft mode header', function() {
+        return this.DraftModeManager.injectDraftMode
+          .calledWith(this.compileDir + '/' + this.rootResourcePath)
+          .should.equal(true)
+      })
+    })
+
+    describe('with a check option', function() {
+      beforeEach(function() {
+        this.request.check = 'error'
+        return this.CompileManager.doCompile(this.request, this.callback)
+      })
+
+      return it('should run chktex', function() {
+        return this.LatexRunner.runLatex
+          .calledWith(`${this.project_id}-${this.user_id}`, {
+            directory: this.compileDir,
+            mainFile: this.rootResourcePath,
+            compiler: this.compiler,
+            timeout: this.timeout,
+            image: this.image,
+            flags: this.flags,
+            environment: {
+              CHKTEX_OPTIONS: '-nall -e9 -e10 -w15 -w16',
+              CHKTEX_EXIT_ON_ERROR: 1,
+              CHKTEX_ULIMIT_OPTIONS: '-t 5 -v 64000'
+            }
+          })
+          .should.equal(true)
+      })
+    })
+
+    return describe('with a knitr file and check options', function() {
+      beforeEach(function() {
+        this.request.rootResourcePath = 'main.Rtex'
+        this.request.check = 'error'
+        return this.CompileManager.doCompile(this.request, this.callback)
+      })
+
+      return it('should not run chktex', function() {
+        return this.LatexRunner.runLatex
+          .calledWith(`${this.project_id}-${this.user_id}`, {
+            directory: this.compileDir,
+            mainFile: 'main.Rtex',
+            compiler: this.compiler,
+            timeout: this.timeout,
+            image: this.image,
+            flags: this.flags,
+            environment: this.env
+          })
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('clearProject', function() {
+    describe('succesfully', function() {
+      beforeEach(function() {
+        this.Settings.compileDir = 'compiles'
+        this.fs.lstat = sinon.stub().callsArgWith(1, null, {
+          isDirectory() {
+            return true
+          }
+        })
+        this.proc = new EventEmitter()
+        this.proc.stdout = new EventEmitter()
+        this.proc.stderr = new EventEmitter()
+        this.child_process.spawn = sinon.stub().returns(this.proc)
+        this.CompileManager.clearProject(
+          this.project_id,
+          this.user_id,
+          this.callback
+        )
+        return this.proc.emit('close', 0)
+      })
+
+      it('should remove the project directory', function() {
+        return this.child_process.spawn
+          .calledWith('rm', [
+            '-r',
+            `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`
+          ])
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('with a non-success status code', function() {
+      beforeEach(function() {
+        this.Settings.compileDir = 'compiles'
+        this.fs.lstat = sinon.stub().callsArgWith(1, null, {
+          isDirectory() {
+            return true
+          }
+        })
+        this.proc = new EventEmitter()
+        this.proc.stdout = new EventEmitter()
+        this.proc.stderr = new EventEmitter()
+        this.child_process.spawn = sinon.stub().returns(this.proc)
+        this.CompileManager.clearProject(
+          this.project_id,
+          this.user_id,
+          this.callback
+        )
+        this.proc.stderr.emit('data', (this.error = 'oops'))
+        return this.proc.emit('close', 1)
+      })
+
+      it('should remove the project directory', function() {
+        return this.child_process.spawn
+          .calledWith('rm', [
+            '-r',
+            `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`
+          ])
+          .should.equal(true)
+      })
+
+      return it('should call the callback with an error from the stderr', function() {
+        this.callback.calledWith(new Error()).should.equal(true)
+
+        return this.callback.args[0][0].message.should.equal(
+          `rm -r ${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id} failed: ${this.error}`
+        )
+      })
+    })
+  })
+
+  describe('syncing', function() {
+    beforeEach(function() {
+      this.page = 1
+      this.h = 42.23
+      this.v = 87.56
+      this.width = 100.01
+      this.height = 234.56
+      this.line = 5
+      this.column = 3
+      this.file_name = 'main.tex'
+      this.child_process.execFile = sinon.stub()
+      return (this.Settings.path.synctexBaseDir = project_id =>
+        `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`)
+    })
+
+    describe('syncFromCode', function() {
+      beforeEach(function() {
+        this.fs.stat = sinon.stub().callsArgWith(1, null, {
+          isFile() {
+            return true
+          }
+        })
+        this.stdout = `NODE\t${this.page}\t${this.h}\t${this.v}\t${this.width}\t${this.height}\n`
+        this.CommandRunner.run = sinon
+          .stub()
+          .callsArgWith(6, null, { stdout: this.stdout })
+        return this.CompileManager.syncFromCode(
+          this.project_id,
+          this.user_id,
+          this.file_name,
+          this.line,
+          this.column,
+          this.callback
+        )
+      })
+
+      it('should execute the synctex binary', function() {
+        const bin_path = Path.resolve(__dirname + '/../../../bin/synctex')
+        const synctex_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/output.pdf`
+        const file_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/${this.file_name}`
+        return this.CommandRunner.run
+          .calledWith(
+            `${this.project_id}-${this.user_id}`,
+            [
+              '/opt/synctex',
+              'code',
+              synctex_path,
+              file_path,
+              this.line,
+              this.column
+            ],
+            `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`,
+            this.Settings.clsi.docker.image,
+            60000,
+            {}
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the parsed output', function() {
+        return this.callback
+          .calledWith(null, [
+            {
+              page: this.page,
+              h: this.h,
+              v: this.v,
+              height: this.height,
+              width: this.width
+            }
+          ])
+          .should.equal(true)
+      })
+    })
+
+    return describe('syncFromPdf', function() {
+      beforeEach(function() {
+        this.fs.stat = sinon.stub().callsArgWith(1, null, {
+          isFile() {
+            return true
+          }
+        })
+        this.stdout = `NODE\t${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/${this.file_name}\t${this.line}\t${this.column}\n`
+        this.CommandRunner.run = sinon
+          .stub()
+          .callsArgWith(6, null, { stdout: this.stdout })
+        return this.CompileManager.syncFromPdf(
+          this.project_id,
+          this.user_id,
+          this.page,
+          this.h,
+          this.v,
+          this.callback
+        )
+      })
+
+      it('should execute the synctex binary', function() {
+        const bin_path = Path.resolve(__dirname + '/../../../bin/synctex')
+        const synctex_path = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}/output.pdf`
+        return this.CommandRunner.run
+          .calledWith(
+            `${this.project_id}-${this.user_id}`,
+            ['/opt/synctex', 'pdf', synctex_path, this.page, this.h, this.v],
+            `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`,
+            this.Settings.clsi.docker.image,
+            60000,
+            {}
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the parsed output', function() {
+        return this.callback
+          .calledWith(null, [
+            {
+              file: this.file_name,
+              line: this.line,
+              column: this.column
+            }
+          ])
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('wordcount', function() {
+    beforeEach(function() {
+      this.CommandRunner.run = sinon.stub().callsArg(6)
+      this.fs.readFile = sinon
+        .stub()
+        .callsArgWith(
+          2,
+          null,
+          (this.stdout = 'Encoding: ascii\nWords in text: 2')
+        )
+      this.callback = sinon.stub()
+
+      this.project_id
+      this.timeout = 60 * 1000
+      this.file_name = 'main.tex'
+      this.Settings.path.compilesDir = '/local/compile/directory'
+      this.image = 'example.com/image'
+
+      return this.CompileManager.wordcount(
+        this.project_id,
+        this.user_id,
+        this.file_name,
+        this.image,
+        this.callback
+      )
+    })
+
+    it('should run the texcount command', function() {
+      this.directory = `${this.Settings.path.compilesDir}/${this.project_id}-${this.user_id}`
+      this.file_path = `$COMPILE_DIR/${this.file_name}`
+      this.command = [
+        'texcount',
+        '-nocol',
+        '-inc',
+        this.file_path,
+        `-out=${this.file_path}.wc`
+      ]
+
+      return this.CommandRunner.run
+        .calledWith(
+          `${this.project_id}-${this.user_id}`,
+          this.command,
+          this.directory,
+          this.image,
+          this.timeout,
+          {}
+        )
+        .should.equal(true)
+    })
+
+    return it('should call the callback with the parsed output', function() {
+      return this.callback
+        .calledWith(null, {
+          encode: 'ascii',
+          textWords: 2,
+          headWords: 0,
+          outside: 0,
+          headers: 0,
+          elements: 0,
+          mathInline: 0,
+          mathDisplay: 0,
+          errors: 0,
+          messages: ''
+        })
+        .should.equal(true)
+    })
+  })
+})

+ 57 - 58
services/clsi/test/unit/js/ContentTypeMapperTests.js

@@ -10,73 +10,72 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/ContentTypeMapper');
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/ContentTypeMapper'
+)
 
 describe('ContentTypeMapper', function() {
+  beforeEach(function() {
+    return (this.ContentTypeMapper = SandboxedModule.require(modulePath))
+  })
 
-	beforeEach(function() {
-		return this.ContentTypeMapper = SandboxedModule.require(modulePath);
-	});
+  return describe('map', function() {
+    it('should map .txt to text/plain', function() {
+      const content_type = this.ContentTypeMapper.map('example.txt')
+      return content_type.should.equal('text/plain')
+    })
 
-	return describe('map', function() {
+    it('should map .csv to text/csv', function() {
+      const content_type = this.ContentTypeMapper.map('example.csv')
+      return content_type.should.equal('text/csv')
+    })
 
-		it('should map .txt to text/plain', function() {
-			const content_type = this.ContentTypeMapper.map('example.txt');
-			return content_type.should.equal('text/plain');
-		});
+    it('should map .pdf to application/pdf', function() {
+      const content_type = this.ContentTypeMapper.map('example.pdf')
+      return content_type.should.equal('application/pdf')
+    })
 
-		it('should map .csv to text/csv', function() {
-			const content_type = this.ContentTypeMapper.map('example.csv');
-			return content_type.should.equal('text/csv');
-		});
+    it('should fall back to octet-stream', function() {
+      const content_type = this.ContentTypeMapper.map('example.unknown')
+      return content_type.should.equal('application/octet-stream')
+    })
 
-		it('should map .pdf to application/pdf', function() {
-			const content_type = this.ContentTypeMapper.map('example.pdf');
-			return content_type.should.equal('application/pdf');
-		});
+    describe('coercing web files to plain text', function() {
+      it('should map .js to plain text', function() {
+        const content_type = this.ContentTypeMapper.map('example.js')
+        return content_type.should.equal('text/plain')
+      })
 
-		it('should fall back to octet-stream', function() {
-			const content_type = this.ContentTypeMapper.map('example.unknown');
-			return content_type.should.equal('application/octet-stream');
-		});
+      it('should map .html to plain text', function() {
+        const content_type = this.ContentTypeMapper.map('example.html')
+        return content_type.should.equal('text/plain')
+      })
 
-		describe('coercing web files to plain text', function() {
+      return it('should map .css to plain text', function() {
+        const content_type = this.ContentTypeMapper.map('example.css')
+        return content_type.should.equal('text/plain')
+      })
+    })
 
-			it('should map .js to plain text', function() {
-				const content_type = this.ContentTypeMapper.map('example.js');
-				return content_type.should.equal('text/plain');
-			});
+    return describe('image files', function() {
+      it('should map .png to image/png', function() {
+        const content_type = this.ContentTypeMapper.map('example.png')
+        return content_type.should.equal('image/png')
+      })
 
-			it('should map .html to plain text', function() {
-				const content_type = this.ContentTypeMapper.map('example.html');
-				return content_type.should.equal('text/plain');
-			});
+      it('should map .jpeg to image/jpeg', function() {
+        const content_type = this.ContentTypeMapper.map('example.jpeg')
+        return content_type.should.equal('image/jpeg')
+      })
 
-			return it('should map .css to plain text', function() {
-				const content_type = this.ContentTypeMapper.map('example.css');
-				return content_type.should.equal('text/plain');
-			});
-		});
-
-		return describe('image files', function() {
-
-			it('should map .png to image/png', function() {
-				const content_type = this.ContentTypeMapper.map('example.png');
-				return content_type.should.equal('image/png');
-			});
-
-			it('should map .jpeg to image/jpeg', function() {
-				const content_type = this.ContentTypeMapper.map('example.jpeg');
-				return content_type.should.equal('image/jpeg');
-			});
-				
-			return it('should map .svg to text/plain to protect against XSS (SVG can execute JS)', function() {
-				const content_type = this.ContentTypeMapper.map('example.svg');
-				return content_type.should.equal('text/plain');
-			});
-		});
-	});
-});
+      return it('should map .svg to text/plain to protect against XSS (SVG can execute JS)', function() {
+        const content_type = this.ContentTypeMapper.map('example.svg')
+        return content_type.should.equal('text/plain')
+      })
+    })
+  })
+})

+ 241 - 182
services/clsi/test/unit/js/DockerLockManagerTests.js

@@ -9,185 +9,244 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-require("coffee-script");
-const modulePath = require('path').join(__dirname, '../../../app/coffee/DockerLockManager');
-
-describe("LockManager", function() {
-	beforeEach(function() {
-		return this.LockManager = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.Settings =
-				{clsi: {docker: {}}}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() })
-		}
-	});});
-
-	return describe("runWithLock", function() {
-		describe("with a single lock", function() {
-			beforeEach(function(done) {
-				this.callback  = sinon.stub();
-				return this.LockManager.runWithLock("lock-one", releaseLock =>
-					setTimeout(() => releaseLock(null, "hello", "world")
-					, 100)
-				
-				, (err, ...args) => {
-					this.callback(err,...Array.from(args));
-					return done();
-				});
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWith(null,"hello","world").should.equal(true);
-			});
-		});
-
-		describe("with two locks", function() {
-			beforeEach(function(done) {
-				this.callback1 = sinon.stub();
-				this.callback2 = sinon.stub();
-				this.LockManager.runWithLock("lock-one", releaseLock =>
-					setTimeout(() => releaseLock(null, "hello", "world","one")
-					, 100)
-				
-				, (err, ...args) => {
-					return this.callback1(err,...Array.from(args));
-				});
-				return this.LockManager.runWithLock("lock-two", releaseLock =>
-					setTimeout(() => releaseLock(null, "hello", "world","two")
-					, 200)
-				
-				, (err, ...args) => {
-					this.callback2(err,...Array.from(args));
-					return done();
-				});
-			});
-
-			it("should call the first callback", function() {
-				return this.callback1.calledWith(null,"hello","world","one").should.equal(true);
-			});
-
-			return it("should call the second callback", function() {
-				return this.callback2.calledWith(null,"hello","world","two").should.equal(true);
-			});
-		});
-
-		return describe("with lock contention", function() {
-			describe("where the first lock is released quickly", function() {
-				beforeEach(function(done) {
-					this.LockManager.MAX_LOCK_WAIT_TIME = 1000;
-					this.LockManager.LOCK_TEST_INTERVAL = 100;
-					this.callback1 = sinon.stub();
-					this.callback2 = sinon.stub();
-					this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","one")
-						, 100)
-					
-					, (err, ...args) => {
-						return this.callback1(err,...Array.from(args));
-					});
-					return this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","two")
-						, 200)
-					
-					, (err, ...args) => {
-						this.callback2(err,...Array.from(args));
-						return done();
-					});
-				});
-
-				it("should call the first callback", function() {
-					return this.callback1.calledWith(null,"hello","world","one").should.equal(true);
-				});
-
-				return it("should call the second callback", function() {
-					return this.callback2.calledWith(null,"hello","world","two").should.equal(true);
-				});
-			});
-
-			describe("where the first lock is held longer than the waiting time", function() {
-				beforeEach(function(done) {
-					let doneTwo;
-					this.LockManager.MAX_LOCK_HOLD_TIME = 10000;
-					this.LockManager.MAX_LOCK_WAIT_TIME = 1000;
-					this.LockManager.LOCK_TEST_INTERVAL = 100;
-					this.callback1 = sinon.stub();
-					this.callback2 = sinon.stub();
-					let doneOne = (doneTwo = false);
-					const finish = function(key) {
-						if (key === 1) { doneOne = true; }
-						if (key === 2) { doneTwo = true; }
-						if (doneOne && doneTwo) { return done(); }
-					};
-					this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","one")
-						, 1100)
-					
-					, (err, ...args) => {
-						this.callback1(err,...Array.from(args));
-						return finish(1);
-					});
-					return this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","two")
-						, 100)
-					
-					, (err, ...args) => {
-						this.callback2(err,...Array.from(args));
-						return finish(2);
-					});
-				});
-
-				it("should call the first callback", function() {
-					return this.callback1.calledWith(null,"hello","world","one").should.equal(true);
-				});
-
-				return it("should call the second callback with an error", function() {
-					const error = sinon.match.instanceOf(Error);
-					return this.callback2.calledWith(error).should.equal(true);
-				});
-			});
-
-			return describe("where the first lock is held longer than the max holding time", function() {
-				beforeEach(function(done) {
-					let doneTwo;
-					this.LockManager.MAX_LOCK_HOLD_TIME = 1000;
-					this.LockManager.MAX_LOCK_WAIT_TIME = 2000;
-					this.LockManager.LOCK_TEST_INTERVAL = 100;
-					this.callback1 = sinon.stub();
-					this.callback2 = sinon.stub();
-					let doneOne = (doneTwo = false);
-					const finish = function(key) {
-						if (key === 1) { doneOne = true; }
-						if (key === 2) { doneTwo = true; }
-						if (doneOne && doneTwo) { return done(); }
-					};
-					this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","one")
-						, 1500)
-					
-					, (err, ...args) => {
-						this.callback1(err,...Array.from(args));
-						return finish(1);
-					});
-					return this.LockManager.runWithLock("lock", releaseLock =>
-						setTimeout(() => releaseLock(null, "hello", "world","two")
-						, 100)
-					
-					, (err, ...args) => {
-						this.callback2(err,...Array.from(args));
-						return finish(2);
-					});
-				});
-
-				it("should call the first callback", function() {
-					return this.callback1.calledWith(null,"hello","world","one").should.equal(true);
-				});
-
-				return it("should call the second callback", function() {
-					return this.callback2.calledWith(null,"hello","world","two").should.equal(true);
-				});
-			});
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+require('coffee-script')
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/coffee/DockerLockManager'
+)
+
+describe('LockManager', function() {
+  beforeEach(function() {
+    return (this.LockManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.Settings = { clsi: { docker: {} } }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        })
+      }
+    }))
+  })
+
+  return describe('runWithLock', function() {
+    describe('with a single lock', function() {
+      beforeEach(function(done) {
+        this.callback = sinon.stub()
+        return this.LockManager.runWithLock(
+          'lock-one',
+          releaseLock =>
+            setTimeout(() => releaseLock(null, 'hello', 'world'), 100),
+
+          (err, ...args) => {
+            this.callback(err, ...Array.from(args))
+            return done()
+          }
+        )
+      })
+
+      return it('should call the callback', function() {
+        return this.callback
+          .calledWith(null, 'hello', 'world')
+          .should.equal(true)
+      })
+    })
+
+    describe('with two locks', function() {
+      beforeEach(function(done) {
+        this.callback1 = sinon.stub()
+        this.callback2 = sinon.stub()
+        this.LockManager.runWithLock(
+          'lock-one',
+          releaseLock =>
+            setTimeout(() => releaseLock(null, 'hello', 'world', 'one'), 100),
+
+          (err, ...args) => {
+            return this.callback1(err, ...Array.from(args))
+          }
+        )
+        return this.LockManager.runWithLock(
+          'lock-two',
+          releaseLock =>
+            setTimeout(() => releaseLock(null, 'hello', 'world', 'two'), 200),
+
+          (err, ...args) => {
+            this.callback2(err, ...Array.from(args))
+            return done()
+          }
+        )
+      })
+
+      it('should call the first callback', function() {
+        return this.callback1
+          .calledWith(null, 'hello', 'world', 'one')
+          .should.equal(true)
+      })
+
+      return it('should call the second callback', function() {
+        return this.callback2
+          .calledWith(null, 'hello', 'world', 'two')
+          .should.equal(true)
+      })
+    })
+
+    return describe('with lock contention', function() {
+      describe('where the first lock is released quickly', function() {
+        beforeEach(function(done) {
+          this.LockManager.MAX_LOCK_WAIT_TIME = 1000
+          this.LockManager.LOCK_TEST_INTERVAL = 100
+          this.callback1 = sinon.stub()
+          this.callback2 = sinon.stub()
+          this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(() => releaseLock(null, 'hello', 'world', 'one'), 100),
+
+            (err, ...args) => {
+              return this.callback1(err, ...Array.from(args))
+            }
+          )
+          return this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(() => releaseLock(null, 'hello', 'world', 'two'), 200),
+
+            (err, ...args) => {
+              this.callback2(err, ...Array.from(args))
+              return done()
+            }
+          )
+        })
+
+        it('should call the first callback', function() {
+          return this.callback1
+            .calledWith(null, 'hello', 'world', 'one')
+            .should.equal(true)
+        })
+
+        return it('should call the second callback', function() {
+          return this.callback2
+            .calledWith(null, 'hello', 'world', 'two')
+            .should.equal(true)
+        })
+      })
+
+      describe('where the first lock is held longer than the waiting time', function() {
+        beforeEach(function(done) {
+          let doneTwo
+          this.LockManager.MAX_LOCK_HOLD_TIME = 10000
+          this.LockManager.MAX_LOCK_WAIT_TIME = 1000
+          this.LockManager.LOCK_TEST_INTERVAL = 100
+          this.callback1 = sinon.stub()
+          this.callback2 = sinon.stub()
+          let doneOne = (doneTwo = false)
+          const finish = function(key) {
+            if (key === 1) {
+              doneOne = true
+            }
+            if (key === 2) {
+              doneTwo = true
+            }
+            if (doneOne && doneTwo) {
+              return done()
+            }
+          }
+          this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(
+                () => releaseLock(null, 'hello', 'world', 'one'),
+                1100
+              ),
+
+            (err, ...args) => {
+              this.callback1(err, ...Array.from(args))
+              return finish(1)
+            }
+          )
+          return this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(() => releaseLock(null, 'hello', 'world', 'two'), 100),
+
+            (err, ...args) => {
+              this.callback2(err, ...Array.from(args))
+              return finish(2)
+            }
+          )
+        })
+
+        it('should call the first callback', function() {
+          return this.callback1
+            .calledWith(null, 'hello', 'world', 'one')
+            .should.equal(true)
+        })
+
+        return it('should call the second callback with an error', function() {
+          const error = sinon.match.instanceOf(Error)
+          return this.callback2.calledWith(error).should.equal(true)
+        })
+      })
+
+      return describe('where the first lock is held longer than the max holding time', function() {
+        beforeEach(function(done) {
+          let doneTwo
+          this.LockManager.MAX_LOCK_HOLD_TIME = 1000
+          this.LockManager.MAX_LOCK_WAIT_TIME = 2000
+          this.LockManager.LOCK_TEST_INTERVAL = 100
+          this.callback1 = sinon.stub()
+          this.callback2 = sinon.stub()
+          let doneOne = (doneTwo = false)
+          const finish = function(key) {
+            if (key === 1) {
+              doneOne = true
+            }
+            if (key === 2) {
+              doneTwo = true
+            }
+            if (doneOne && doneTwo) {
+              return done()
+            }
+          }
+          this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(
+                () => releaseLock(null, 'hello', 'world', 'one'),
+                1500
+              ),
+
+            (err, ...args) => {
+              this.callback1(err, ...Array.from(args))
+              return finish(1)
+            }
+          )
+          return this.LockManager.runWithLock(
+            'lock',
+            releaseLock =>
+              setTimeout(() => releaseLock(null, 'hello', 'world', 'two'), 100),
+
+            (err, ...args) => {
+              this.callback2(err, ...Array.from(args))
+              return finish(2)
+            }
+          )
+        })
+
+        it('should call the first callback', function() {
+          return this.callback1
+            .calledWith(null, 'hello', 'world', 'one')
+            .should.equal(true)
+        })
+
+        return it('should call the second callback', function() {
+          return this.callback2
+            .calledWith(null, 'hello', 'world', 'two')
+            .should.equal(true)
+        })
+      })
+    })
+  })
+})

+ 823 - 648
services/clsi/test/unit/js/DockerRunnerTests.js

@@ -13,651 +13,826 @@
  * DS207: Consider shorter variations of null checks
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const { expect } = require('chai');
-require("coffee-script");
-const modulePath = require('path').join(__dirname, '../../../app/coffee/DockerRunner');
-const Path = require("path");
-
-describe("DockerRunner", function() {
-	beforeEach(function() {
-		let container, Docker, Timer;
-		this.container = (container = {});
-		this.DockerRunner = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.Settings = {
-				clsi: { docker: {}
-			},
-				path: {}
-			}),
-			"logger-sharelatex": (this.logger = {
-				log: sinon.stub(),
-				error: sinon.stub(),
-				info: sinon.stub(),
-				warn: sinon.stub()
-			}),
-			"dockerode": (Docker = (function() {
-				Docker = class Docker {
-					static initClass() {
-						this.prototype.getContainer = sinon.stub().returns(container);
-						this.prototype.createContainer = sinon.stub().yields(null, container);
-						this.prototype.listContainers = sinon.stub();
-					}
-				};
-				Docker.initClass();
-				return Docker;
-			})()),
-			"fs": (this.fs = { stat: sinon.stub().yields(null,{isDirectory(){ return true; }}) }),
-			"./Metrics": {
-				Timer: (Timer = class Timer {
-					done() {}
-				})
-			},
-			"./LockManager": {
-				runWithLock(key, runner, callback) { return runner(callback); }
-			}
-		}
-	}
-		);
-		this.Docker = Docker;
-		this.getContainer = Docker.prototype.getContainer;
-		this.createContainer = Docker.prototype.createContainer;
-		this.listContainers = Docker.prototype.listContainers;
-
-		this.directory = "/local/compile/directory";
-		this.mainFile  = "main-file.tex";
-		this.compiler  = "pdflatex";
-		this.image     = "example.com/sharelatex/image:2016.2";
-		this.env       = {};
-		this.callback  = sinon.stub();
-		this.project_id = "project-id-123";
-		this.volumes =
-			{"/local/compile/directory": "/compile"};
-		this.Settings.clsi.docker.image = (this.defaultImage = "default-image");
-		return this.Settings.clsi.docker.env   = {PATH: "mock-path"};
-	});
-
-	describe("run", function() {
-		beforeEach(function(done){
-			this.DockerRunner._getContainerOptions = sinon.stub().returns(this.options = {mockoptions: "foo"});
-			this.DockerRunner._fingerprintContainer = sinon.stub().returns(this.fingerprint = "fingerprint");
-
-			this.name = `project-${this.project_id}-${this.fingerprint}`;
-
-			this.command = ["mock", "command", "--outdir=$COMPILE_DIR"];
-			this.command_with_dir = ["mock", "command", "--outdir=/compile"];
-			this.timeout = 42000;
-			return done();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function(done){
-				this.DockerRunner._runAndWaitForContainer = sinon.stub().callsArgWith(3, null, (this.output = "mock-output"));
-				return this.DockerRunner.run(this.project_id, this.command, this.directory, this.image, this.timeout, this.env, (err, output)=> {
-					this.callback(err, output);
-					return done();
-				});
-			});
-
-			it("should generate the options for the container", function() {
-				return this.DockerRunner._getContainerOptions
-					.calledWith(this.command_with_dir, this.image, this.volumes, this.timeout)
-					.should.equal(true);
-			});
-
-			it("should generate the fingerprint from the returned options", function() {
-				return this.DockerRunner._fingerprintContainer
-					.calledWith(this.options)
-					.should.equal(true);
-			});
-
-			it("should do the run", function() {
-				return this.DockerRunner._runAndWaitForContainer	
-					.calledWith(this.options, this.volumes, this.timeout)		
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWith(null, this.output).should.equal(true);
-			});
-		});
-
-		describe('when path.sandboxedCompilesHostDir is set', function() {
-
-			beforeEach(function() {
-				this.Settings.path.sandboxedCompilesHostDir = '/some/host/dir/compiles';
-				this.directory = '/var/lib/sharelatex/data/compiles/xyz';
-				this.DockerRunner._runAndWaitForContainer = sinon.stub().callsArgWith(3, null, (this.output = "mock-output"));
-				return this.DockerRunner.run(this.project_id, this.command, this.directory, this.image, this.timeout, this.env, this.callback);
-			});
-
-			it('should re-write the bind directory', function() {
-				const volumes = this.DockerRunner._runAndWaitForContainer.lastCall.args[1];
-				return expect(volumes).to.deep.equal({
-					'/some/host/dir/compiles/xyz': '/compile'
-				});
-		});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWith(null, this.output).should.equal(true);
-			});
-		});
-
-		describe("when the run throws an error", function() {
-			beforeEach(function() {
-				let firstTime = true;
-				this.output = "mock-output";
-				this.DockerRunner._runAndWaitForContainer = (options, volumes, timeout, callback) => {
-					if (callback == null) { callback = function(error, output){}; }
-					if (firstTime) {
-						firstTime = false;
-						return callback(new Error("HTTP code is 500 which indicates error: server error"));
-					} else {
-						return callback(null, this.output);
-					}
-				};
-				sinon.spy(this.DockerRunner, "_runAndWaitForContainer");
-				this.DockerRunner.destroyContainer = sinon.stub().callsArg(3);
-				return this.DockerRunner.run(this.project_id, this.command, this.directory, this.image, this.timeout, this.env, this.callback);
-			});
-
-			it("should do the run twice", function() {
-				return this.DockerRunner._runAndWaitForContainer	
-					.calledTwice.should.equal(true);
-			});
-
-			it("should destroy the container in between", function() {
-				return this.DockerRunner.destroyContainer
-					.calledWith(this.name, null)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWith(null, this.output).should.equal(true);
-			});
-		});
-		
-		describe("with no image", function() {
-			beforeEach(function() {
-				this.DockerRunner._runAndWaitForContainer = sinon.stub().callsArgWith(3, null, (this.output = "mock-output"));
-				return this.DockerRunner.run(this.project_id, this.command, this.directory, null, this.timeout, this.env, this.callback);
-			});
-
-			return it("should use the default image", function() {
-				return this.DockerRunner._getContainerOptions
-					.calledWith(this.command_with_dir, this.defaultImage, this.volumes, this.timeout)
-					.should.equal(true);
-			});
-		});
-		
-		return describe("with image override", function() {
-			beforeEach(function() {
-				this.Settings.texliveImageNameOveride = "overrideimage.com/something";
-				this.DockerRunner._runAndWaitForContainer = sinon.stub().callsArgWith(3, null, (this.output = "mock-output"));
-				return this.DockerRunner.run(this.project_id, this.command, this.directory, this.image, this.timeout, this.env, this.callback);
-			});
-
-			return it("should use the override and keep the tag", function() {
-				const image = this.DockerRunner._getContainerOptions.args[0][1];
-				return image.should.equal("overrideimage.com/something/image:2016.2");
-			});
-		});
-	});
-
-	describe("_runAndWaitForContainer", function() {
-		beforeEach(function() {
-			this.options = {mockoptions: "foo", name: (this.name = "mock-name")};
-			this.DockerRunner.startContainer = (options, volumes, attachStreamHandler, callback) => {
-				attachStreamHandler(null, (this.output = "mock-output"));
-				return callback(null, (this.containerId = "container-id"));
-			};
-			sinon.spy(this.DockerRunner, "startContainer");
-			this.DockerRunner.waitForContainer = sinon.stub().callsArgWith(2, null, (this.exitCode = 42));
-			return this.DockerRunner._runAndWaitForContainer(this.options, this.volumes, this.timeout, this.callback);
-		});
-
-		it("should create/start the container", function() {
-			return this.DockerRunner.startContainer
-				.calledWith(this.options, this.volumes)
-				.should.equal(true);
-		});
-
-		it("should wait for the container to finish", function() {
-			return this.DockerRunner.waitForContainer
-				.calledWith(this.name, this.timeout)
-				.should.equal(true);
-		});
-
-		return it("should call the callback with the output", function() {
-			return this.callback.calledWith(null, this.output).should.equal(true);
-		});
-	});
-
-	describe("startContainer", function() {
-		beforeEach(function() {
-			this.attachStreamHandler = sinon.stub();
-			this.attachStreamHandler.cock = true;
-			this.options = {mockoptions: "foo", name: "mock-name"};
-			this.container.inspect = sinon.stub().callsArgWith(0);
-			this.DockerRunner.attachToContainer = (containerId, attachStreamHandler, cb)=> { 
-				attachStreamHandler();
-				return cb();
-			};
-			return sinon.spy(this.DockerRunner, "attachToContainer");
-		});
-
-
-
-		describe("when the container exists", function() {
-			beforeEach(function() {
-				this.container.inspect = sinon.stub().callsArgWith(0);
-				this.container.start = sinon.stub().yields();
-
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.callback, () => {});
-			});
-
-			it("should start the container with the given name", function() {
-				this.getContainer
-					.calledWith(this.options.name)
-					.should.equal(true);
-				return this.container.start
-					.called
-					.should.equal(true);
-			});
-
-			it("should not try to create the container", function() {
-				return this.createContainer.called.should.equal(false);
-			});
-			
-			it("should attach to the container", function() {
-				return this.DockerRunner.attachToContainer.called.should.equal(true);
-			});
-
-			it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-	
-			return it("should attach before the container starts", function() {
-				return sinon.assert.callOrder(this.DockerRunner.attachToContainer, this.container.start);
-			});
-		});
-						
-		describe("when the container does not exist", function() {
-			beforeEach(function(){
-				const exists = false;
-				this.container.start = sinon.stub().yields();
-				this.container.inspect = sinon.stub().callsArgWith(0, {statusCode:404});
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.attachStreamHandler, this.callback);
-			});
-
-			it("should create the container", function() {
-				return this.createContainer
-					.calledWith(this.options)
-					.should.equal(true);
-			});
-
-			it("should call the callback and stream handler", function() {
-				this.attachStreamHandler.called.should.equal(true);
-				return this.callback.called.should.equal(true);
-			});
-			
-			it("should attach to the container", function() {
-				return this.DockerRunner.attachToContainer.called.should.equal(true);
-			});
-
-			return it("should attach before the container starts", function() {
-				return sinon.assert.callOrder(this.DockerRunner.attachToContainer, this.container.start);
-			});
-		});
-
-
-		describe("when the container is already running", function() {
-			beforeEach(function() {
-				const error = new Error(`HTTP code is 304 which indicates error: server error - start: Cannot start container ${this.name}: The container MOCKID is already running.`);
-				error.statusCode = 304;
-				this.container.start = sinon.stub().yields(error);
-				this.container.inspect = sinon.stub().callsArgWith(0);
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.attachStreamHandler, this.callback);
-			});
-
-			it("should not try to create the container", function() {
-				return this.createContainer.called.should.equal(false);
-			});
-
-			return it("should call the callback  and stream handler without an error", function() {
-				this.attachStreamHandler.called.should.equal(true);
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("when a volume does not exist", function() {
-			beforeEach(function(){
-				this.fs.stat = sinon.stub().yields(new Error("no such path"));
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.attachStreamHandler, this.callback);
-			});
-
-			it("should not try to create the container", function() {
-				return this.createContainer.called.should.equal(false);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error()).should.equal(true);
-			});
-		});
-
-		describe("when a volume exists but is not a directory", function() {
-			beforeEach(function() {
-				this.fs.stat = sinon.stub().yields(null, {isDirectory() { return false; }});
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.attachStreamHandler, this.callback);
-			});
-
-			it("should not try to create the container", function() {
-				return this.createContainer.called.should.equal(false);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error()).should.equal(true);
-			});
-		});
-
-		describe("when a volume does not exist, but sibling-containers are used", function() {
-			beforeEach(function() {
-				this.fs.stat = sinon.stub().yields(new Error("no such path"));
-				this.Settings.path.sandboxedCompilesHostDir = '/some/path';
-				this.container.start = sinon.stub().yields();
-				return this.DockerRunner.startContainer(this.options, this.volumes, this.callback);
-			});
-
-			afterEach(function() {
-				return delete this.Settings.path.sandboxedCompilesHostDir;
-			});
-
-			it("should start the container with the given name", function() {
-				this.getContainer
-					.calledWith(this.options.name)
-					.should.equal(true);
-				return this.container.start
-					.called
-					.should.equal(true);
-			});
-
-			it("should not try to create the container", function() {
-				return this.createContainer.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				this.callback.called.should.equal(true);
-				return this.callback.calledWith(new Error()).should.equal(false);
-			});
-		});
-
-		return describe("when the container tries to be created, but already has been (race condition)", function() {});
-	});
-
-	describe("waitForContainer", function() {
-		beforeEach(function() {
-			this.containerId = "container-id";
-			this.timeout = 5000;
-			this.container.wait = sinon.stub().yields(null, {StatusCode: (this.statusCode = 42)});
-			return this.container.kill = sinon.stub().yields();
-		});
-			
-		describe("when the container returns in time", function() {
-			beforeEach(function() {
-				return this.DockerRunner.waitForContainer(this.containerId, this.timeout, this.callback);
-			});
-
-			it("should wait for the container", function() {
-				this.getContainer
-					.calledWith(this.containerId)
-					.should.equal(true);
-				return this.container.wait
-					.called
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the exit", function() {
-				return this.callback
-					.calledWith(null, this.statusCode)
-					.should.equal(true);
-			});
-		});
-
-		return describe("when the container does not return before the timeout", function() {
-			beforeEach(function(done) {
-				this.container.wait = function(callback) {
-					if (callback == null) { callback = function(error, exitCode) {}; }
-					return setTimeout(() => callback(null, {StatusCode: 42})
-					, 100);
-				};
-				this.timeout = 5;
-				return this.DockerRunner.waitForContainer(this.containerId, this.timeout, (...args) => {
-					this.callback(...Array.from(args || []));
-					return done();
-				});
-			});
-
-			it("should call kill on the container", function() {
-				this.getContainer
-					.calledWith(this.containerId)
-					.should.equal(true);
-				return this.container.kill
-					.called
-					.should.equal(true);
-			});
-
-			return it("should call the callback with an error", function() {
-				const error = new Error("container timed out");
-				error.timedout = true;
-				return this.callback
-					.calledWith(error)
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("destroyOldContainers", function() {
-		beforeEach(function(done) {
-			const oneHourInSeconds = 60 * 60;
-			const oneHourInMilliseconds = oneHourInSeconds * 1000;
-			const nowInSeconds = Date.now()/1000;
-			this.containers = [{
-				Name: "/project-old-container-name",
-				Id: "old-container-id",
-				Created: nowInSeconds - oneHourInSeconds - 100
-			}, {
-				Name: "/project-new-container-name",
-				Id: "new-container-id",
-				Created: (nowInSeconds - oneHourInSeconds) + 100
-			}, {
-				Name: "/totally-not-a-project-container",
-				Id: "some-random-id",
-				Created: nowInSeconds - (2 * oneHourInSeconds )
-			}];
-			this.DockerRunner.MAX_CONTAINER_AGE = oneHourInMilliseconds;
-			this.listContainers.callsArgWith(1, null, this.containers);
-			this.DockerRunner.destroyContainer = sinon.stub().callsArg(3);
-			return this.DockerRunner.destroyOldContainers(error => {
-				this.callback(error);
-				return done();
-			});
-		});
-
-		it("should list all containers", function() {
-			return this.listContainers
-				.calledWith({all: true})
-				.should.equal(true);
-		});
-
-		it("should destroy old containers", function() {
-			this.DockerRunner.destroyContainer
-				.callCount
-				.should.equal(1);
-			return this.DockerRunner.destroyContainer
-				.calledWith("/project-old-container-name", "old-container-id")
-				.should.equal(true);
-		});
-
-		it("should not destroy new containers", function() {
-			return this.DockerRunner.destroyContainer
-				.calledWith("/project-new-container-name", "new-container-id")
-				.should.equal(false);
-		});
-
-		it("should not destroy non-project containers", function() {
-			return this.DockerRunner.destroyContainer
-				.calledWith("/totally-not-a-project-container", "some-random-id")
-				.should.equal(false);
-		});
-
-		return it("should callback the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-
-	describe('_destroyContainer', function() {
-		beforeEach(function() {
-			this.containerId = 'some_id';
-			this.fakeContainer =
-				{remove: sinon.stub().callsArgWith(1, null)};
-			return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-		});
-
-		it('should get the container', function(done) {
-			return this.DockerRunner._destroyContainer(this.containerId, false, err => {
-				this.Docker.prototype.getContainer.callCount.should.equal(1);
-				this.Docker.prototype.getContainer.calledWith(this.containerId).should.equal(true);
-				return done();
-			});
-		});
-
-		it('should try to force-destroy the container when shouldForce=true', function(done) {
-			return this.DockerRunner._destroyContainer(this.containerId, true, err => {
-				this.fakeContainer.remove.callCount.should.equal(1);
-				this.fakeContainer.remove.calledWith({force: true}).should.equal(true);
-				return done();
-			});
-		});
-
-		it('should not try to force-destroy the container when shouldForce=false', function(done) {
-			return this.DockerRunner._destroyContainer(this.containerId, false, err => {
-				this.fakeContainer.remove.callCount.should.equal(1);
-				this.fakeContainer.remove.calledWith({force: false}).should.equal(true);
-				return done();
-			});
-		});
-
-		it('should not produce an error', function(done) {
-			return this.DockerRunner._destroyContainer(this.containerId, false, err => {
-				expect(err).to.equal(null);
-				return done();
-			});
-		});
-
-		describe('when the container is already gone', function() {
-			beforeEach(function() {
-				this.fakeError = new Error('woops');
-				this.fakeError.statusCode = 404;
-				this.fakeContainer =
-					{remove: sinon.stub().callsArgWith(1, this.fakeError)};
-				return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-			});
-
-			return it('should not produce an error', function(done) {
-				return this.DockerRunner._destroyContainer(this.containerId, false, err => {
-					expect(err).to.equal(null);
-					return done();
-				});
-			});
-		});
-
-		return describe('when container.destroy produces an error', function(done) {
-			beforeEach(function() {
-				this.fakeError = new Error('woops');
-				this.fakeError.statusCode = 500;
-				this.fakeContainer =
-					{remove: sinon.stub().callsArgWith(1, this.fakeError)};
-				return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-			});
-
-			return it('should produce an error', function(done) {
-				return this.DockerRunner._destroyContainer(this.containerId, false, err => {
-					expect(err).to.not.equal(null);
-					expect(err).to.equal(this.fakeError);
-					return done();
-				});
-			});
-		});
-	});
-
-
-	return describe('kill', function() {
-		beforeEach(function() {
-			this.containerId = 'some_id';
-			this.fakeContainer =
-				{kill: sinon.stub().callsArgWith(0, null)};
-			return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-		});
-
-		it('should get the container', function(done) {
-			return this.DockerRunner.kill(this.containerId, err => {
-				this.Docker.prototype.getContainer.callCount.should.equal(1);
-				this.Docker.prototype.getContainer.calledWith(this.containerId).should.equal(true);
-				return done();
-			});
-		});
-
-		it('should try to force-destroy the container', function(done) {
-			return this.DockerRunner.kill(this.containerId, err => {
-				this.fakeContainer.kill.callCount.should.equal(1);
-				return done();
-			});
-		});
-
-		it('should not produce an error', function(done) {
-			return this.DockerRunner.kill(this.containerId, err => {
-				expect(err).to.equal(undefined);
-				return done();
-			});
-		});
-
-		describe('when the container is not actually running', function() {
-			beforeEach(function() {
-				this.fakeError = new Error('woops');
-				this.fakeError.statusCode = 500;
-				this.fakeError.message = 'Cannot kill container <whatever> is not running';
-				this.fakeContainer =
-					{kill: sinon.stub().callsArgWith(0, this.fakeError)};
-				return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-			});
-
-			return it('should not produce an error', function(done) {
-				return this.DockerRunner.kill(this.containerId, err => {
-					expect(err).to.equal(undefined);
-					return done();
-				});
-			});
-		});
-
-		return describe('when container.kill produces a legitimate error', function(done) {
-			beforeEach(function() {
-				this.fakeError = new Error('woops');
-				this.fakeError.statusCode = 500;
-				this.fakeError.message = 'Totally legitimate reason to throw an error';
-				this.fakeContainer =
-					{kill: sinon.stub().callsArgWith(0, this.fakeError)};
-				return this.Docker.prototype.getContainer = sinon.stub().returns(this.fakeContainer);
-			});
-
-			return it('should produce an error', function(done) {
-				return this.DockerRunner.kill(this.containerId, err => {
-					expect(err).to.not.equal(undefined);
-					expect(err).to.equal(this.fakeError);
-					return done();
-				});
-			});
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const { expect } = require('chai')
+require('coffee-script')
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/coffee/DockerRunner'
+)
+const Path = require('path')
+
+describe('DockerRunner', function() {
+  beforeEach(function() {
+    let container, Docker, Timer
+    this.container = container = {}
+    this.DockerRunner = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.Settings = {
+          clsi: { docker: {} },
+          path: {}
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          info: sinon.stub(),
+          warn: sinon.stub()
+        }),
+        dockerode: (Docker = (function() {
+          Docker = class Docker {
+            static initClass() {
+              this.prototype.getContainer = sinon.stub().returns(container)
+              this.prototype.createContainer = sinon
+                .stub()
+                .yields(null, container)
+              this.prototype.listContainers = sinon.stub()
+            }
+          }
+          Docker.initClass()
+          return Docker
+        })()),
+        fs: (this.fs = {
+          stat: sinon.stub().yields(null, {
+            isDirectory() {
+              return true
+            }
+          })
+        }),
+        './Metrics': {
+          Timer: (Timer = class Timer {
+            done() {}
+          })
+        },
+        './LockManager': {
+          runWithLock(key, runner, callback) {
+            return runner(callback)
+          }
+        }
+      }
+    })
+    this.Docker = Docker
+    this.getContainer = Docker.prototype.getContainer
+    this.createContainer = Docker.prototype.createContainer
+    this.listContainers = Docker.prototype.listContainers
+
+    this.directory = '/local/compile/directory'
+    this.mainFile = 'main-file.tex'
+    this.compiler = 'pdflatex'
+    this.image = 'example.com/sharelatex/image:2016.2'
+    this.env = {}
+    this.callback = sinon.stub()
+    this.project_id = 'project-id-123'
+    this.volumes = { '/local/compile/directory': '/compile' }
+    this.Settings.clsi.docker.image = this.defaultImage = 'default-image'
+    return (this.Settings.clsi.docker.env = { PATH: 'mock-path' })
+  })
+
+  describe('run', function() {
+    beforeEach(function(done) {
+      this.DockerRunner._getContainerOptions = sinon
+        .stub()
+        .returns((this.options = { mockoptions: 'foo' }))
+      this.DockerRunner._fingerprintContainer = sinon
+        .stub()
+        .returns((this.fingerprint = 'fingerprint'))
+
+      this.name = `project-${this.project_id}-${this.fingerprint}`
+
+      this.command = ['mock', 'command', '--outdir=$COMPILE_DIR']
+      this.command_with_dir = ['mock', 'command', '--outdir=/compile']
+      this.timeout = 42000
+      return done()
+    })
+
+    describe('successfully', function() {
+      beforeEach(function(done) {
+        this.DockerRunner._runAndWaitForContainer = sinon
+          .stub()
+          .callsArgWith(3, null, (this.output = 'mock-output'))
+        return this.DockerRunner.run(
+          this.project_id,
+          this.command,
+          this.directory,
+          this.image,
+          this.timeout,
+          this.env,
+          (err, output) => {
+            this.callback(err, output)
+            return done()
+          }
+        )
+      })
+
+      it('should generate the options for the container', function() {
+        return this.DockerRunner._getContainerOptions
+          .calledWith(
+            this.command_with_dir,
+            this.image,
+            this.volumes,
+            this.timeout
+          )
+          .should.equal(true)
+      })
+
+      it('should generate the fingerprint from the returned options', function() {
+        return this.DockerRunner._fingerprintContainer
+          .calledWith(this.options)
+          .should.equal(true)
+      })
+
+      it('should do the run', function() {
+        return this.DockerRunner._runAndWaitForContainer
+          .calledWith(this.options, this.volumes, this.timeout)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.calledWith(null, this.output).should.equal(true)
+      })
+    })
+
+    describe('when path.sandboxedCompilesHostDir is set', function() {
+      beforeEach(function() {
+        this.Settings.path.sandboxedCompilesHostDir = '/some/host/dir/compiles'
+        this.directory = '/var/lib/sharelatex/data/compiles/xyz'
+        this.DockerRunner._runAndWaitForContainer = sinon
+          .stub()
+          .callsArgWith(3, null, (this.output = 'mock-output'))
+        return this.DockerRunner.run(
+          this.project_id,
+          this.command,
+          this.directory,
+          this.image,
+          this.timeout,
+          this.env,
+          this.callback
+        )
+      })
+
+      it('should re-write the bind directory', function() {
+        const volumes = this.DockerRunner._runAndWaitForContainer.lastCall
+          .args[1]
+        return expect(volumes).to.deep.equal({
+          '/some/host/dir/compiles/xyz': '/compile'
+        })
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.calledWith(null, this.output).should.equal(true)
+      })
+    })
+
+    describe('when the run throws an error', function() {
+      beforeEach(function() {
+        let firstTime = true
+        this.output = 'mock-output'
+        this.DockerRunner._runAndWaitForContainer = (
+          options,
+          volumes,
+          timeout,
+          callback
+        ) => {
+          if (callback == null) {
+            callback = function(error, output) {}
+          }
+          if (firstTime) {
+            firstTime = false
+            return callback(
+              new Error('HTTP code is 500 which indicates error: server error')
+            )
+          } else {
+            return callback(null, this.output)
+          }
+        }
+        sinon.spy(this.DockerRunner, '_runAndWaitForContainer')
+        this.DockerRunner.destroyContainer = sinon.stub().callsArg(3)
+        return this.DockerRunner.run(
+          this.project_id,
+          this.command,
+          this.directory,
+          this.image,
+          this.timeout,
+          this.env,
+          this.callback
+        )
+      })
+
+      it('should do the run twice', function() {
+        return this.DockerRunner._runAndWaitForContainer.calledTwice.should.equal(
+          true
+        )
+      })
+
+      it('should destroy the container in between', function() {
+        return this.DockerRunner.destroyContainer
+          .calledWith(this.name, null)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.calledWith(null, this.output).should.equal(true)
+      })
+    })
+
+    describe('with no image', function() {
+      beforeEach(function() {
+        this.DockerRunner._runAndWaitForContainer = sinon
+          .stub()
+          .callsArgWith(3, null, (this.output = 'mock-output'))
+        return this.DockerRunner.run(
+          this.project_id,
+          this.command,
+          this.directory,
+          null,
+          this.timeout,
+          this.env,
+          this.callback
+        )
+      })
+
+      return it('should use the default image', function() {
+        return this.DockerRunner._getContainerOptions
+          .calledWith(
+            this.command_with_dir,
+            this.defaultImage,
+            this.volumes,
+            this.timeout
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('with image override', function() {
+      beforeEach(function() {
+        this.Settings.texliveImageNameOveride = 'overrideimage.com/something'
+        this.DockerRunner._runAndWaitForContainer = sinon
+          .stub()
+          .callsArgWith(3, null, (this.output = 'mock-output'))
+        return this.DockerRunner.run(
+          this.project_id,
+          this.command,
+          this.directory,
+          this.image,
+          this.timeout,
+          this.env,
+          this.callback
+        )
+      })
+
+      return it('should use the override and keep the tag', function() {
+        const image = this.DockerRunner._getContainerOptions.args[0][1]
+        return image.should.equal('overrideimage.com/something/image:2016.2')
+      })
+    })
+  })
+
+  describe('_runAndWaitForContainer', function() {
+    beforeEach(function() {
+      this.options = { mockoptions: 'foo', name: (this.name = 'mock-name') }
+      this.DockerRunner.startContainer = (
+        options,
+        volumes,
+        attachStreamHandler,
+        callback
+      ) => {
+        attachStreamHandler(null, (this.output = 'mock-output'))
+        return callback(null, (this.containerId = 'container-id'))
+      }
+      sinon.spy(this.DockerRunner, 'startContainer')
+      this.DockerRunner.waitForContainer = sinon
+        .stub()
+        .callsArgWith(2, null, (this.exitCode = 42))
+      return this.DockerRunner._runAndWaitForContainer(
+        this.options,
+        this.volumes,
+        this.timeout,
+        this.callback
+      )
+    })
+
+    it('should create/start the container', function() {
+      return this.DockerRunner.startContainer
+        .calledWith(this.options, this.volumes)
+        .should.equal(true)
+    })
+
+    it('should wait for the container to finish', function() {
+      return this.DockerRunner.waitForContainer
+        .calledWith(this.name, this.timeout)
+        .should.equal(true)
+    })
+
+    return it('should call the callback with the output', function() {
+      return this.callback.calledWith(null, this.output).should.equal(true)
+    })
+  })
+
+  describe('startContainer', function() {
+    beforeEach(function() {
+      this.attachStreamHandler = sinon.stub()
+      this.attachStreamHandler.cock = true
+      this.options = { mockoptions: 'foo', name: 'mock-name' }
+      this.container.inspect = sinon.stub().callsArgWith(0)
+      this.DockerRunner.attachToContainer = (
+        containerId,
+        attachStreamHandler,
+        cb
+      ) => {
+        attachStreamHandler()
+        return cb()
+      }
+      return sinon.spy(this.DockerRunner, 'attachToContainer')
+    })
+
+    describe('when the container exists', function() {
+      beforeEach(function() {
+        this.container.inspect = sinon.stub().callsArgWith(0)
+        this.container.start = sinon.stub().yields()
+
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.callback,
+          () => {}
+        )
+      })
+
+      it('should start the container with the given name', function() {
+        this.getContainer.calledWith(this.options.name).should.equal(true)
+        return this.container.start.called.should.equal(true)
+      })
+
+      it('should not try to create the container', function() {
+        return this.createContainer.called.should.equal(false)
+      })
+
+      it('should attach to the container', function() {
+        return this.DockerRunner.attachToContainer.called.should.equal(true)
+      })
+
+      it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+
+      return it('should attach before the container starts', function() {
+        return sinon.assert.callOrder(
+          this.DockerRunner.attachToContainer,
+          this.container.start
+        )
+      })
+    })
+
+    describe('when the container does not exist', function() {
+      beforeEach(function() {
+        const exists = false
+        this.container.start = sinon.stub().yields()
+        this.container.inspect = sinon
+          .stub()
+          .callsArgWith(0, { statusCode: 404 })
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.attachStreamHandler,
+          this.callback
+        )
+      })
+
+      it('should create the container', function() {
+        return this.createContainer.calledWith(this.options).should.equal(true)
+      })
+
+      it('should call the callback and stream handler', function() {
+        this.attachStreamHandler.called.should.equal(true)
+        return this.callback.called.should.equal(true)
+      })
+
+      it('should attach to the container', function() {
+        return this.DockerRunner.attachToContainer.called.should.equal(true)
+      })
+
+      return it('should attach before the container starts', function() {
+        return sinon.assert.callOrder(
+          this.DockerRunner.attachToContainer,
+          this.container.start
+        )
+      })
+    })
+
+    describe('when the container is already running', function() {
+      beforeEach(function() {
+        const error = new Error(
+          `HTTP code is 304 which indicates error: server error - start: Cannot start container ${this.name}: The container MOCKID is already running.`
+        )
+        error.statusCode = 304
+        this.container.start = sinon.stub().yields(error)
+        this.container.inspect = sinon.stub().callsArgWith(0)
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.attachStreamHandler,
+          this.callback
+        )
+      })
+
+      it('should not try to create the container', function() {
+        return this.createContainer.called.should.equal(false)
+      })
+
+      return it('should call the callback  and stream handler without an error', function() {
+        this.attachStreamHandler.called.should.equal(true)
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('when a volume does not exist', function() {
+      beforeEach(function() {
+        this.fs.stat = sinon.stub().yields(new Error('no such path'))
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.attachStreamHandler,
+          this.callback
+        )
+      })
+
+      it('should not try to create the container', function() {
+        return this.createContainer.called.should.equal(false)
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback.calledWith(new Error()).should.equal(true)
+      })
+    })
+
+    describe('when a volume exists but is not a directory', function() {
+      beforeEach(function() {
+        this.fs.stat = sinon.stub().yields(null, {
+          isDirectory() {
+            return false
+          }
+        })
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.attachStreamHandler,
+          this.callback
+        )
+      })
+
+      it('should not try to create the container', function() {
+        return this.createContainer.called.should.equal(false)
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback.calledWith(new Error()).should.equal(true)
+      })
+    })
+
+    describe('when a volume does not exist, but sibling-containers are used', function() {
+      beforeEach(function() {
+        this.fs.stat = sinon.stub().yields(new Error('no such path'))
+        this.Settings.path.sandboxedCompilesHostDir = '/some/path'
+        this.container.start = sinon.stub().yields()
+        return this.DockerRunner.startContainer(
+          this.options,
+          this.volumes,
+          this.callback
+        )
+      })
+
+      afterEach(function() {
+        return delete this.Settings.path.sandboxedCompilesHostDir
+      })
+
+      it('should start the container with the given name', function() {
+        this.getContainer.calledWith(this.options.name).should.equal(true)
+        return this.container.start.called.should.equal(true)
+      })
+
+      it('should not try to create the container', function() {
+        return this.createContainer.called.should.equal(false)
+      })
+
+      return it('should call the callback', function() {
+        this.callback.called.should.equal(true)
+        return this.callback.calledWith(new Error()).should.equal(false)
+      })
+    })
+
+    return describe('when the container tries to be created, but already has been (race condition)', function() {})
+  })
+
+  describe('waitForContainer', function() {
+    beforeEach(function() {
+      this.containerId = 'container-id'
+      this.timeout = 5000
+      this.container.wait = sinon
+        .stub()
+        .yields(null, { StatusCode: (this.statusCode = 42) })
+      return (this.container.kill = sinon.stub().yields())
+    })
+
+    describe('when the container returns in time', function() {
+      beforeEach(function() {
+        return this.DockerRunner.waitForContainer(
+          this.containerId,
+          this.timeout,
+          this.callback
+        )
+      })
+
+      it('should wait for the container', function() {
+        this.getContainer.calledWith(this.containerId).should.equal(true)
+        return this.container.wait.called.should.equal(true)
+      })
+
+      return it('should call the callback with the exit', function() {
+        return this.callback
+          .calledWith(null, this.statusCode)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the container does not return before the timeout', function() {
+      beforeEach(function(done) {
+        this.container.wait = function(callback) {
+          if (callback == null) {
+            callback = function(error, exitCode) {}
+          }
+          return setTimeout(() => callback(null, { StatusCode: 42 }), 100)
+        }
+        this.timeout = 5
+        return this.DockerRunner.waitForContainer(
+          this.containerId,
+          this.timeout,
+          (...args) => {
+            this.callback(...Array.from(args || []))
+            return done()
+          }
+        )
+      })
+
+      it('should call kill on the container', function() {
+        this.getContainer.calledWith(this.containerId).should.equal(true)
+        return this.container.kill.called.should.equal(true)
+      })
+
+      return it('should call the callback with an error', function() {
+        const error = new Error('container timed out')
+        error.timedout = true
+        return this.callback.calledWith(error).should.equal(true)
+      })
+    })
+  })
+
+  describe('destroyOldContainers', function() {
+    beforeEach(function(done) {
+      const oneHourInSeconds = 60 * 60
+      const oneHourInMilliseconds = oneHourInSeconds * 1000
+      const nowInSeconds = Date.now() / 1000
+      this.containers = [
+        {
+          Name: '/project-old-container-name',
+          Id: 'old-container-id',
+          Created: nowInSeconds - oneHourInSeconds - 100
+        },
+        {
+          Name: '/project-new-container-name',
+          Id: 'new-container-id',
+          Created: nowInSeconds - oneHourInSeconds + 100
+        },
+        {
+          Name: '/totally-not-a-project-container',
+          Id: 'some-random-id',
+          Created: nowInSeconds - 2 * oneHourInSeconds
+        }
+      ]
+      this.DockerRunner.MAX_CONTAINER_AGE = oneHourInMilliseconds
+      this.listContainers.callsArgWith(1, null, this.containers)
+      this.DockerRunner.destroyContainer = sinon.stub().callsArg(3)
+      return this.DockerRunner.destroyOldContainers(error => {
+        this.callback(error)
+        return done()
+      })
+    })
+
+    it('should list all containers', function() {
+      return this.listContainers.calledWith({ all: true }).should.equal(true)
+    })
+
+    it('should destroy old containers', function() {
+      this.DockerRunner.destroyContainer.callCount.should.equal(1)
+      return this.DockerRunner.destroyContainer
+        .calledWith('/project-old-container-name', 'old-container-id')
+        .should.equal(true)
+    })
+
+    it('should not destroy new containers', function() {
+      return this.DockerRunner.destroyContainer
+        .calledWith('/project-new-container-name', 'new-container-id')
+        .should.equal(false)
+    })
+
+    it('should not destroy non-project containers', function() {
+      return this.DockerRunner.destroyContainer
+        .calledWith('/totally-not-a-project-container', 'some-random-id')
+        .should.equal(false)
+    })
+
+    return it('should callback the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('_destroyContainer', function() {
+    beforeEach(function() {
+      this.containerId = 'some_id'
+      this.fakeContainer = { remove: sinon.stub().callsArgWith(1, null) }
+      return (this.Docker.prototype.getContainer = sinon
+        .stub()
+        .returns(this.fakeContainer))
+    })
+
+    it('should get the container', function(done) {
+      return this.DockerRunner._destroyContainer(
+        this.containerId,
+        false,
+        err => {
+          this.Docker.prototype.getContainer.callCount.should.equal(1)
+          this.Docker.prototype.getContainer
+            .calledWith(this.containerId)
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should try to force-destroy the container when shouldForce=true', function(done) {
+      return this.DockerRunner._destroyContainer(
+        this.containerId,
+        true,
+        err => {
+          this.fakeContainer.remove.callCount.should.equal(1)
+          this.fakeContainer.remove
+            .calledWith({ force: true })
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should not try to force-destroy the container when shouldForce=false', function(done) {
+      return this.DockerRunner._destroyContainer(
+        this.containerId,
+        false,
+        err => {
+          this.fakeContainer.remove.callCount.should.equal(1)
+          this.fakeContainer.remove
+            .calledWith({ force: false })
+            .should.equal(true)
+          return done()
+        }
+      )
+    })
+
+    it('should not produce an error', function(done) {
+      return this.DockerRunner._destroyContainer(
+        this.containerId,
+        false,
+        err => {
+          expect(err).to.equal(null)
+          return done()
+        }
+      )
+    })
+
+    describe('when the container is already gone', function() {
+      beforeEach(function() {
+        this.fakeError = new Error('woops')
+        this.fakeError.statusCode = 404
+        this.fakeContainer = {
+          remove: sinon.stub().callsArgWith(1, this.fakeError)
+        }
+        return (this.Docker.prototype.getContainer = sinon
+          .stub()
+          .returns(this.fakeContainer))
+      })
+
+      return it('should not produce an error', function(done) {
+        return this.DockerRunner._destroyContainer(
+          this.containerId,
+          false,
+          err => {
+            expect(err).to.equal(null)
+            return done()
+          }
+        )
+      })
+    })
+
+    return describe('when container.destroy produces an error', function(done) {
+      beforeEach(function() {
+        this.fakeError = new Error('woops')
+        this.fakeError.statusCode = 500
+        this.fakeContainer = {
+          remove: sinon.stub().callsArgWith(1, this.fakeError)
+        }
+        return (this.Docker.prototype.getContainer = sinon
+          .stub()
+          .returns(this.fakeContainer))
+      })
+
+      return it('should produce an error', function(done) {
+        return this.DockerRunner._destroyContainer(
+          this.containerId,
+          false,
+          err => {
+            expect(err).to.not.equal(null)
+            expect(err).to.equal(this.fakeError)
+            return done()
+          }
+        )
+      })
+    })
+  })
+
+  return describe('kill', function() {
+    beforeEach(function() {
+      this.containerId = 'some_id'
+      this.fakeContainer = { kill: sinon.stub().callsArgWith(0, null) }
+      return (this.Docker.prototype.getContainer = sinon
+        .stub()
+        .returns(this.fakeContainer))
+    })
+
+    it('should get the container', function(done) {
+      return this.DockerRunner.kill(this.containerId, err => {
+        this.Docker.prototype.getContainer.callCount.should.equal(1)
+        this.Docker.prototype.getContainer
+          .calledWith(this.containerId)
+          .should.equal(true)
+        return done()
+      })
+    })
+
+    it('should try to force-destroy the container', function(done) {
+      return this.DockerRunner.kill(this.containerId, err => {
+        this.fakeContainer.kill.callCount.should.equal(1)
+        return done()
+      })
+    })
+
+    it('should not produce an error', function(done) {
+      return this.DockerRunner.kill(this.containerId, err => {
+        expect(err).to.equal(undefined)
+        return done()
+      })
+    })
+
+    describe('when the container is not actually running', function() {
+      beforeEach(function() {
+        this.fakeError = new Error('woops')
+        this.fakeError.statusCode = 500
+        this.fakeError.message =
+          'Cannot kill container <whatever> is not running'
+        this.fakeContainer = {
+          kill: sinon.stub().callsArgWith(0, this.fakeError)
+        }
+        return (this.Docker.prototype.getContainer = sinon
+          .stub()
+          .returns(this.fakeContainer))
+      })
+
+      return it('should not produce an error', function(done) {
+        return this.DockerRunner.kill(this.containerId, err => {
+          expect(err).to.equal(undefined)
+          return done()
+        })
+      })
+    })
+
+    return describe('when container.kill produces a legitimate error', function(done) {
+      beforeEach(function() {
+        this.fakeError = new Error('woops')
+        this.fakeError.statusCode = 500
+        this.fakeError.message = 'Totally legitimate reason to throw an error'
+        this.fakeContainer = {
+          kill: sinon.stub().callsArgWith(0, this.fakeError)
+        }
+        return (this.Docker.prototype.getContainer = sinon
+          .stub()
+          .returns(this.fakeContainer))
+      })
+
+      return it('should produce an error', function(done) {
+        return this.DockerRunner.kill(this.containerId, err => {
+          expect(err).to.not.equal(undefined)
+          expect(err).to.equal(this.fakeError)
+          return done()
+        })
+      })
+    })
+  })
+})

+ 61 - 57
services/clsi/test/unit/js/DraftModeManagerTests.js

@@ -8,75 +8,79 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/DraftModeManager');
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/DraftModeManager'
+)
 
 describe('DraftModeManager', function() {
-	beforeEach(function() {
-		return this.DraftModeManager = SandboxedModule.require(modulePath, { requires: {
-			"fs": (this.fs = {}),
-			"logger-sharelatex": (this.logger = {log() {}})
-		}
-	});});
-		
-	describe("_injectDraftOption", function() {
-		it("should add draft option into documentclass with existing options", function() {
-			return this.DraftModeManager
-				._injectDraftOption(`\
+  beforeEach(function() {
+    return (this.DraftModeManager = SandboxedModule.require(modulePath, {
+      requires: {
+        fs: (this.fs = {}),
+        'logger-sharelatex': (this.logger = { log() {} })
+      }
+    }))
+  })
+
+  describe('_injectDraftOption', function() {
+    it('should add draft option into documentclass with existing options', function() {
+      return this.DraftModeManager._injectDraftOption(`\
 \\documentclass[a4paper,foo=bar]{article}\
-`)
-				.should.equal(`\
+`).should.equal(`\
 \\documentclass[draft,a4paper,foo=bar]{article}\
-`);
-		});
+`)
+    })
 
-		return it("should add draft option into documentclass with no options", function() {
-			return this.DraftModeManager
-				._injectDraftOption(`\
+    return it('should add draft option into documentclass with no options', function() {
+      return this.DraftModeManager._injectDraftOption(`\
 \\documentclass{article}\
-`)
-				.should.equal(`\
+`).should.equal(`\
 \\documentclass[draft]{article}\
-`);
-		});
-	});
-	
-	return describe("injectDraftMode", function() {
-		beforeEach(function() {
-			this.filename = "/mock/filename.tex";
-			this.callback = sinon.stub();
-			const content = `\
+`)
+    })
+  })
+
+  return describe('injectDraftMode', function() {
+    beforeEach(function() {
+      this.filename = '/mock/filename.tex'
+      this.callback = sinon.stub()
+      const content = `\
 \\documentclass{article}
 \\begin{document}
 Hello world
 \\end{document}\
-`;
-			this.fs.readFile = sinon.stub().callsArgWith(2, null, content);
-			this.fs.writeFile = sinon.stub().callsArg(2);
-			return this.DraftModeManager.injectDraftMode(this.filename, this.callback);
-		});
-	
-		it("should read the file", function() {
-			return this.fs.readFile
-				.calledWith(this.filename, "utf8")
-				.should.equal(true);
-		});
-		
-		it("should write the modified file", function() {
-			return this.fs.writeFile
-				.calledWith(this.filename, `\
+`
+      this.fs.readFile = sinon.stub().callsArgWith(2, null, content)
+      this.fs.writeFile = sinon.stub().callsArg(2)
+      return this.DraftModeManager.injectDraftMode(this.filename, this.callback)
+    })
+
+    it('should read the file', function() {
+      return this.fs.readFile
+        .calledWith(this.filename, 'utf8')
+        .should.equal(true)
+    })
+
+    it('should write the modified file', function() {
+      return this.fs.writeFile
+        .calledWith(
+          this.filename,
+          `\
 \\documentclass[draft]{article}
 \\begin{document}
 Hello world
 \\end{document}\
-`)
-				.should.equal(true);
-		});
-		
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
+`
+        )
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 117 - 91
services/clsi/test/unit/js/LatexRunnerTests.js

@@ -9,103 +9,129 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/LatexRunner');
-const Path = require("path");
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/LatexRunner'
+)
+const Path = require('path')
 
-describe("LatexRunner", function() {
-	beforeEach(function() {
-		let Timer;
-		this.LatexRunner = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.Settings = {
-				docker: {
-					socketPath: "/var/run/docker.sock"
-				}
-			}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"./Metrics": {
-				Timer: (Timer = class Timer {
-					done() {}
-				})
-			},
-			"./CommandRunner": (this.CommandRunner = {})
-		}
-	});
+describe('LatexRunner', function() {
+  beforeEach(function() {
+    let Timer
+    this.LatexRunner = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.Settings = {
+          docker: {
+            socketPath: '/var/run/docker.sock'
+          }
+        }),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        './Metrics': {
+          Timer: (Timer = class Timer {
+            done() {}
+          })
+        },
+        './CommandRunner': (this.CommandRunner = {})
+      }
+    })
 
-		this.directory = "/local/compile/directory";
-		this.mainFile  = "main-file.tex";
-		this.compiler  = "pdflatex";
-		this.image     = "example.com/image";
-		this.callback  = sinon.stub();
-		this.project_id = "project-id-123";
-		return this.env       = {'foo': '123'};});
+    this.directory = '/local/compile/directory'
+    this.mainFile = 'main-file.tex'
+    this.compiler = 'pdflatex'
+    this.image = 'example.com/image'
+    this.callback = sinon.stub()
+    this.project_id = 'project-id-123'
+    return (this.env = { foo: '123' })
+  })
 
-	return describe("runLatex", function() {
-		beforeEach(function() {
-			return this.CommandRunner.run = sinon.stub().callsArg(6);
-		});
+  return describe('runLatex', function() {
+    beforeEach(function() {
+      return (this.CommandRunner.run = sinon.stub().callsArg(6))
+    })
 
-		describe("normally", function() {
-			beforeEach(function() {
-				return this.LatexRunner.runLatex(this.project_id, {
-					directory: this.directory,
-					mainFile:  this.mainFile,
-					compiler:  this.compiler,
-					timeout:   (this.timeout = 42000),
-					image:     this.image,
-					environment: this.env
-				},
-					this.callback);
-			});
+    describe('normally', function() {
+      beforeEach(function() {
+        return this.LatexRunner.runLatex(
+          this.project_id,
+          {
+            directory: this.directory,
+            mainFile: this.mainFile,
+            compiler: this.compiler,
+            timeout: (this.timeout = 42000),
+            image: this.image,
+            environment: this.env
+          },
+          this.callback
+        )
+      })
 
-			return it("should run the latex command", function() {
-				return this.CommandRunner.run
-					.calledWith(this.project_id, sinon.match.any, this.directory, this.image, this.timeout, this.env)
-					.should.equal(true);
-			});
-		});
+      return it('should run the latex command', function() {
+        return this.CommandRunner.run
+          .calledWith(
+            this.project_id,
+            sinon.match.any,
+            this.directory,
+            this.image,
+            this.timeout,
+            this.env
+          )
+          .should.equal(true)
+      })
+    })
 
-		describe("with an .Rtex main file", function() {
-			beforeEach(function() {
-				return this.LatexRunner.runLatex(this.project_id, {
-					directory: this.directory,
-					mainFile:  "main-file.Rtex",
-					compiler:  this.compiler,
-					image:     this.image,
-					timeout:   (this.timeout = 42000)
-				},
-					this.callback);
-			});
+    describe('with an .Rtex main file', function() {
+      beforeEach(function() {
+        return this.LatexRunner.runLatex(
+          this.project_id,
+          {
+            directory: this.directory,
+            mainFile: 'main-file.Rtex',
+            compiler: this.compiler,
+            image: this.image,
+            timeout: (this.timeout = 42000)
+          },
+          this.callback
+        )
+      })
 
-			return it("should run the latex command on the equivalent .tex file", function() {
-				const command = this.CommandRunner.run.args[0][1];
-				const mainFile = command.slice(-1)[0];
-				return mainFile.should.equal("$COMPILE_DIR/main-file.tex");
-			});
-		});
+      return it('should run the latex command on the equivalent .tex file', function() {
+        const command = this.CommandRunner.run.args[0][1]
+        const mainFile = command.slice(-1)[0]
+        return mainFile.should.equal('$COMPILE_DIR/main-file.tex')
+      })
+    })
 
-		return describe("with a flags option", function() {
-			beforeEach(function() {
-				return this.LatexRunner.runLatex(this.project_id, {
-					directory: this.directory,
-					mainFile:  this.mainFile,
-					compiler:  this.compiler,
-					image:     this.image,
-					timeout:   (this.timeout = 42000),
-					flags:     ["-file-line-error", "-halt-on-error"]
-				},
-					this.callback);
-			});
+    return describe('with a flags option', function() {
+      beforeEach(function() {
+        return this.LatexRunner.runLatex(
+          this.project_id,
+          {
+            directory: this.directory,
+            mainFile: this.mainFile,
+            compiler: this.compiler,
+            image: this.image,
+            timeout: (this.timeout = 42000),
+            flags: ['-file-line-error', '-halt-on-error']
+          },
+          this.callback
+        )
+      })
 
-			return it("should include the flags in the command", function() {
-				const command = this.CommandRunner.run.args[0][1];
-				const flags = command.filter(arg => (arg === "-file-line-error") || (arg === "-halt-on-error"));
-				flags.length.should.equal(2);
-				flags[0].should.equal("-file-line-error");
-				return flags[1].should.equal("-halt-on-error");
-			});
-		});
-	});
-});
+      return it('should include the flags in the command', function() {
+        const command = this.CommandRunner.run.args[0][1]
+        const flags = command.filter(
+          arg => arg === '-file-line-error' || arg === '-halt-on-error'
+        )
+        flags.length.should.equal(2)
+        flags[0].should.equal('-file-line-error')
+        return flags[1].should.equal('-halt-on-error')
+      })
+    })
+  })
+})

+ 74 - 64
services/clsi/test/unit/js/LockManagerTests.js

@@ -9,75 +9,85 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/LockManager');
-const Path = require("path");
-const Errors = require("../../../app/js/Errors");
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/LockManager'
+)
+const Path = require('path')
+const Errors = require('../../../app/js/Errors')
 
-describe("DockerLockManager", function() {
-	beforeEach(function() {
-		this.LockManager = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": {},
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub(), err() {} }),
-			"fs": {
-				lstat:sinon.stub().callsArgWith(1),
-				readdir: sinon.stub().callsArgWith(1)
-			},
-			"lockfile": (this.Lockfile = {})
-		}
-	});
-		return this.lockFile = "/local/compile/directory/.project-lock";
-	});
+describe('DockerLockManager', function() {
+  beforeEach(function() {
+    this.LockManager = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': {},
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub(),
+          err() {}
+        }),
+        fs: {
+          lstat: sinon.stub().callsArgWith(1),
+          readdir: sinon.stub().callsArgWith(1)
+        },
+        lockfile: (this.Lockfile = {})
+      }
+    })
+    return (this.lockFile = '/local/compile/directory/.project-lock')
+  })
 
-	return describe("runWithLock", function() {
-		beforeEach(function() {
-			this.runner = sinon.stub().callsArgWith(0, null, "foo", "bar");
-			return this.callback = sinon.stub();
-		});
+  return describe('runWithLock', function() {
+    beforeEach(function() {
+      this.runner = sinon.stub().callsArgWith(0, null, 'foo', 'bar')
+      return (this.callback = sinon.stub())
+    })
 
-		describe("normally", function() {
-			beforeEach(function() {
-				this.Lockfile.lock = sinon.stub().callsArgWith(2, null);
-				this.Lockfile.unlock = sinon.stub().callsArgWith(1, null);
-				return this.LockManager.runWithLock(this.lockFile, this.runner, this.callback);
-			});
+    describe('normally', function() {
+      beforeEach(function() {
+        this.Lockfile.lock = sinon.stub().callsArgWith(2, null)
+        this.Lockfile.unlock = sinon.stub().callsArgWith(1, null)
+        return this.LockManager.runWithLock(
+          this.lockFile,
+          this.runner,
+          this.callback
+        )
+      })
 
-			it("should run the compile", function() {
-				return this.runner
-					.calledWith()
-					.should.equal(true);
-			});
+      it('should run the compile', function() {
+        return this.runner.calledWith().should.equal(true)
+      })
 
-			return it("should call the callback with the response from the compile", function() {
-				return this.callback
-					.calledWithExactly(null, "foo", "bar")
-					.should.equal(true);
-			});
-		});
+      return it('should call the callback with the response from the compile', function() {
+        return this.callback
+          .calledWithExactly(null, 'foo', 'bar')
+          .should.equal(true)
+      })
+    })
 
-		return describe("when the project is locked", function() {
-			beforeEach(function() {
-				this.error = new Error();
-				this.error.code =  "EEXIST";
-				this.Lockfile.lock = sinon.stub().callsArgWith(2,this.error);
-				this.Lockfile.unlock = sinon.stub().callsArgWith(1, null);
-				return this.LockManager.runWithLock(this.lockFile, this.runner, this.callback);
-			});
+    return describe('when the project is locked', function() {
+      beforeEach(function() {
+        this.error = new Error()
+        this.error.code = 'EEXIST'
+        this.Lockfile.lock = sinon.stub().callsArgWith(2, this.error)
+        this.Lockfile.unlock = sinon.stub().callsArgWith(1, null)
+        return this.LockManager.runWithLock(
+          this.lockFile,
+          this.runner,
+          this.callback
+        )
+      })
 
-			it("should not run the compile", function() {
-				return this.runner
-					.called
-					.should.equal(false);
-			});
+      it('should not run the compile', function() {
+        return this.runner.called.should.equal(false)
+      })
 
-			return it("should return an error", function() {
-				const error = new Errors.AlreadyCompilingError();
-				return this.callback
-					.calledWithExactly(error)
-					.should.equal(true);
-			});
-		});
-	});
-});
+      return it('should return an error', function() {
+        const error = new Errors.AlreadyCompilingError()
+        return this.callback.calledWithExactly(error).should.equal(true)
+      })
+    })
+  })
+})

+ 89 - 83
services/clsi/test/unit/js/OutputFileFinderTests.js

@@ -10,90 +10,96 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/OutputFileFinder');
-const path = require("path");
-const { expect } = require("chai");
-const { EventEmitter } = require("events");
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/OutputFileFinder'
+)
+const path = require('path')
+const { expect } = require('chai')
+const { EventEmitter } = require('events')
 
-describe("OutputFileFinder", function() {
-	beforeEach(function() {
-		this.OutputFileFinder = SandboxedModule.require(modulePath, { requires: {
-			"fs": (this.fs = {}),
-			"child_process": { spawn: (this.spawn = sinon.stub())
-		},
-			"logger-sharelatex": { log: sinon.stub(), warn: sinon.stub() }
-		}
-	});
-		this.directory = "/test/dir";
-		return this.callback = sinon.stub();
-	});
+describe('OutputFileFinder', function() {
+  beforeEach(function() {
+    this.OutputFileFinder = SandboxedModule.require(modulePath, {
+      requires: {
+        fs: (this.fs = {}),
+        child_process: { spawn: (this.spawn = sinon.stub()) },
+        'logger-sharelatex': { log: sinon.stub(), warn: sinon.stub() }
+      }
+    })
+    this.directory = '/test/dir'
+    return (this.callback = sinon.stub())
+  })
 
-	describe("findOutputFiles", function() {
-		beforeEach(function() {
-			this.resource_path = "resource/path.tex";
-			this.output_paths   = ["output.pdf", "extra/file.tex"];
-			this.all_paths = this.output_paths.concat([this.resource_path]);
-			this.resources = [
-				{path: (this.resource_path = "resource/path.tex")}
-			];
-			this.OutputFileFinder._getAllFiles = sinon.stub().callsArgWith(1, null, this.all_paths);
-			return this.OutputFileFinder.findOutputFiles(this.resources, this.directory, (error, outputFiles) => {
-				this.outputFiles = outputFiles;
-				
-		});
-		});
+  describe('findOutputFiles', function() {
+    beforeEach(function() {
+      this.resource_path = 'resource/path.tex'
+      this.output_paths = ['output.pdf', 'extra/file.tex']
+      this.all_paths = this.output_paths.concat([this.resource_path])
+      this.resources = [{ path: (this.resource_path = 'resource/path.tex') }]
+      this.OutputFileFinder._getAllFiles = sinon
+        .stub()
+        .callsArgWith(1, null, this.all_paths)
+      return this.OutputFileFinder.findOutputFiles(
+        this.resources,
+        this.directory,
+        (error, outputFiles) => {
+          this.outputFiles = outputFiles
+        }
+      )
+    })
 
-		return it("should only return the output files, not directories or resource paths", function() {
-			return expect(this.outputFiles).to.deep.equal([{
-				path: "output.pdf",
-				type: "pdf"
-			}, {
-				path: "extra/file.tex",
-				type: "tex"
-			}]);
-	});
-});
-			
-	return describe("_getAllFiles", function() {
-		beforeEach(function() {
-			this.proc = new EventEmitter();
-			this.proc.stdout = new EventEmitter();
-			this.spawn.returns(this.proc);
-			this.directory = "/base/dir";
-			return this.OutputFileFinder._getAllFiles(this.directory, this.callback);
-		});
-			
-		describe("successfully", function() {
-			beforeEach(function() {
-				this.proc.stdout.emit(
-					"data",
-					["/base/dir/main.tex", "/base/dir/chapters/chapter1.tex"].join("\n") + "\n"
-				);
-				return this.proc.emit("close", 0);
-			});
-				
-			return it("should call the callback with the relative file paths", function() {
-				return this.callback.calledWith(
-					null,
-					["main.tex", "chapters/chapter1.tex"]
-				).should.equal(true);
-			});
-		});
+    return it('should only return the output files, not directories or resource paths', function() {
+      return expect(this.outputFiles).to.deep.equal([
+        {
+          path: 'output.pdf',
+          type: 'pdf'
+        },
+        {
+          path: 'extra/file.tex',
+          type: 'tex'
+        }
+      ])
+    })
+  })
 
-		return describe("when the directory doesn't exist", function() {
-			beforeEach(function() {
-				return this.proc.emit("close", 1);
-			});
-				
-			return it("should call the callback with a blank array", function() {
-				return this.callback.calledWith(
-					null,
-					[]
-				).should.equal(true);
-			});
-		});
-	});
-});
+  return describe('_getAllFiles', function() {
+    beforeEach(function() {
+      this.proc = new EventEmitter()
+      this.proc.stdout = new EventEmitter()
+      this.spawn.returns(this.proc)
+      this.directory = '/base/dir'
+      return this.OutputFileFinder._getAllFiles(this.directory, this.callback)
+    })
+
+    describe('successfully', function() {
+      beforeEach(function() {
+        this.proc.stdout.emit(
+          'data',
+          ['/base/dir/main.tex', '/base/dir/chapters/chapter1.tex'].join('\n') +
+            '\n'
+        )
+        return this.proc.emit('close', 0)
+      })
+
+      return it('should call the callback with the relative file paths', function() {
+        return this.callback
+          .calledWith(null, ['main.tex', 'chapters/chapter1.tex'])
+          .should.equal(true)
+      })
+    })
+
+    return describe("when the directory doesn't exist", function() {
+      beforeEach(function() {
+        return this.proc.emit('close', 1)
+      })
+
+      return it('should call the callback with a blank array', function() {
+        return this.callback.calledWith(null, []).should.equal(true)
+      })
+    })
+  })
+})

+ 184 - 136
services/clsi/test/unit/js/OutputFileOptimiserTests.js

@@ -10,139 +10,187 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/OutputFileOptimiser');
-const path = require("path");
-const { expect } = require("chai");
-const { EventEmitter } = require("events");
-
-describe("OutputFileOptimiser", function() {
-	beforeEach(function() {
-		this.OutputFileOptimiser = SandboxedModule.require(modulePath, { requires: {
-			"fs": (this.fs = {}),
-			"path": (this.Path = {}),
-			"child_process": { spawn: (this.spawn = sinon.stub())
-		},
-			"logger-sharelatex": { log: sinon.stub(), warn: sinon.stub() },
-			"./Metrics" : {}
-		}
-	});
-		this.directory = "/test/dir";
-		return this.callback = sinon.stub();
-	});
-
-	describe("optimiseFile", function() {
-		beforeEach(function() {
-			this.src = "./output.pdf";
-			return this.dst = "./output.pdf";
-		});
-
-		describe("when the file is not a pdf file", function() {
-			beforeEach(function(done){
-				this.src = "./output.log";
-				this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon.stub().callsArgWith(1, null, false);
-				this.OutputFileOptimiser.optimisePDF = sinon.stub().callsArgWith(2, null);
-				return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done);
-			});
-
-			it("should not check if the file is optimised", function() {
-				return this.OutputFileOptimiser.checkIfPDFIsOptimised.calledWith(this.src).should.equal(false);
-			});
-
-			return it("should not optimise the file", function() {
-				return this.OutputFileOptimiser.optimisePDF.calledWith(this.src, this.dst).should.equal(false);
-			});
-		});
-
-		describe("when the pdf file is not optimised", function() {
-			beforeEach(function(done) {
-				this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon.stub().callsArgWith(1, null, false);
-				this.OutputFileOptimiser.optimisePDF = sinon.stub().callsArgWith(2, null);
-				return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done);
-			});
-
-			it("should check if the pdf is optimised", function() {
-				return this.OutputFileOptimiser.checkIfPDFIsOptimised.calledWith(this.src).should.equal(true);
-			});
-
-			return it("should optimise the pdf", function() {
-				return this.OutputFileOptimiser.optimisePDF.calledWith(this.src, this.dst).should.equal(true);
-			});
-		});
-
-		return describe("when the pdf file is optimised", function() {
-			beforeEach(function(done) {
-				this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon.stub().callsArgWith(1, null, true);
-				this.OutputFileOptimiser.optimisePDF = sinon.stub().callsArgWith(2, null);
-				return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done);
-			});
-
-			it("should check if the pdf is optimised", function() {
-				return this.OutputFileOptimiser.checkIfPDFIsOptimised.calledWith(this.src).should.equal(true);
-			});
-
-			return it("should not optimise the pdf", function() {
-				return this.OutputFileOptimiser.optimisePDF.calledWith(this.src, this.dst).should.equal(false);
-			});
-		});
-	});
-
-	return describe("checkIfPDFISOptimised", function() {
-		beforeEach(function() {
-			this.callback = sinon.stub();
-			this.fd = 1234;
-			this.fs.open = sinon.stub().yields(null, this.fd);
-			this.fs.read = sinon.stub().withArgs(this.fd).yields(null, 100, new Buffer("hello /Linearized 1"));
-			this.fs.close = sinon.stub().withArgs(this.fd).yields(null);
-			return this.OutputFileOptimiser.checkIfPDFIsOptimised(this.src, this.callback);
-		});
-
-		describe("for a linearised file", function() {
-			beforeEach(function() {
-				this.fs.read = sinon.stub().withArgs(this.fd).yields(null, 100, new Buffer("hello /Linearized 1"));
-				return this.OutputFileOptimiser.checkIfPDFIsOptimised(this.src, this.callback);
-			});
-
-			it("should open the file", function() {
-				return this.fs.open.calledWith(this.src, "r").should.equal(true);
-			});
-
-			it("should read the header", function() {
-				return this.fs.read.calledWith(this.fd).should.equal(true);
-			});
-
-			it("should close the file", function() {
-				return this.fs.close.calledWith(this.fd).should.equal(true);
-			});
-
-			return it("should call the callback with a true result", function() {
-				return this.callback.calledWith(null, true).should.equal(true);
-			});
-		});
-
-		return describe("for an unlinearised file", function() {
-			beforeEach(function() {
-				this.fs.read = sinon.stub().withArgs(this.fd).yields(null, 100, new Buffer("hello not linearized 1"));
-				return this.OutputFileOptimiser.checkIfPDFIsOptimised(this.src, this.callback);
-			});
-
-			it("should open the file", function() {
-				return this.fs.open.calledWith(this.src, "r").should.equal(true);
-			});
-
-			it("should read the header", function() {
-				return this.fs.read.calledWith(this.fd).should.equal(true);
-			});
-
-			it("should close the file", function() {
-				return this.fs.close.calledWith(this.fd).should.equal(true);
-			});
-
-			return it("should call the callback with a false result", function() {
-				return this.callback.calledWith(null, false).should.equal(true);
-			});
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/OutputFileOptimiser'
+)
+const path = require('path')
+const { expect } = require('chai')
+const { EventEmitter } = require('events')
+
+describe('OutputFileOptimiser', function() {
+  beforeEach(function() {
+    this.OutputFileOptimiser = SandboxedModule.require(modulePath, {
+      requires: {
+        fs: (this.fs = {}),
+        path: (this.Path = {}),
+        child_process: { spawn: (this.spawn = sinon.stub()) },
+        'logger-sharelatex': { log: sinon.stub(), warn: sinon.stub() },
+        './Metrics': {}
+      }
+    })
+    this.directory = '/test/dir'
+    return (this.callback = sinon.stub())
+  })
+
+  describe('optimiseFile', function() {
+    beforeEach(function() {
+      this.src = './output.pdf'
+      return (this.dst = './output.pdf')
+    })
+
+    describe('when the file is not a pdf file', function() {
+      beforeEach(function(done) {
+        this.src = './output.log'
+        this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon
+          .stub()
+          .callsArgWith(1, null, false)
+        this.OutputFileOptimiser.optimisePDF = sinon
+          .stub()
+          .callsArgWith(2, null)
+        return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done)
+      })
+
+      it('should not check if the file is optimised', function() {
+        return this.OutputFileOptimiser.checkIfPDFIsOptimised
+          .calledWith(this.src)
+          .should.equal(false)
+      })
+
+      return it('should not optimise the file', function() {
+        return this.OutputFileOptimiser.optimisePDF
+          .calledWith(this.src, this.dst)
+          .should.equal(false)
+      })
+    })
+
+    describe('when the pdf file is not optimised', function() {
+      beforeEach(function(done) {
+        this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon
+          .stub()
+          .callsArgWith(1, null, false)
+        this.OutputFileOptimiser.optimisePDF = sinon
+          .stub()
+          .callsArgWith(2, null)
+        return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done)
+      })
+
+      it('should check if the pdf is optimised', function() {
+        return this.OutputFileOptimiser.checkIfPDFIsOptimised
+          .calledWith(this.src)
+          .should.equal(true)
+      })
+
+      return it('should optimise the pdf', function() {
+        return this.OutputFileOptimiser.optimisePDF
+          .calledWith(this.src, this.dst)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the pdf file is optimised', function() {
+      beforeEach(function(done) {
+        this.OutputFileOptimiser.checkIfPDFIsOptimised = sinon
+          .stub()
+          .callsArgWith(1, null, true)
+        this.OutputFileOptimiser.optimisePDF = sinon
+          .stub()
+          .callsArgWith(2, null)
+        return this.OutputFileOptimiser.optimiseFile(this.src, this.dst, done)
+      })
+
+      it('should check if the pdf is optimised', function() {
+        return this.OutputFileOptimiser.checkIfPDFIsOptimised
+          .calledWith(this.src)
+          .should.equal(true)
+      })
+
+      return it('should not optimise the pdf', function() {
+        return this.OutputFileOptimiser.optimisePDF
+          .calledWith(this.src, this.dst)
+          .should.equal(false)
+      })
+    })
+  })
+
+  return describe('checkIfPDFISOptimised', function() {
+    beforeEach(function() {
+      this.callback = sinon.stub()
+      this.fd = 1234
+      this.fs.open = sinon.stub().yields(null, this.fd)
+      this.fs.read = sinon
+        .stub()
+        .withArgs(this.fd)
+        .yields(null, 100, new Buffer('hello /Linearized 1'))
+      this.fs.close = sinon
+        .stub()
+        .withArgs(this.fd)
+        .yields(null)
+      return this.OutputFileOptimiser.checkIfPDFIsOptimised(
+        this.src,
+        this.callback
+      )
+    })
+
+    describe('for a linearised file', function() {
+      beforeEach(function() {
+        this.fs.read = sinon
+          .stub()
+          .withArgs(this.fd)
+          .yields(null, 100, new Buffer('hello /Linearized 1'))
+        return this.OutputFileOptimiser.checkIfPDFIsOptimised(
+          this.src,
+          this.callback
+        )
+      })
+
+      it('should open the file', function() {
+        return this.fs.open.calledWith(this.src, 'r').should.equal(true)
+      })
+
+      it('should read the header', function() {
+        return this.fs.read.calledWith(this.fd).should.equal(true)
+      })
+
+      it('should close the file', function() {
+        return this.fs.close.calledWith(this.fd).should.equal(true)
+      })
+
+      return it('should call the callback with a true result', function() {
+        return this.callback.calledWith(null, true).should.equal(true)
+      })
+    })
+
+    return describe('for an unlinearised file', function() {
+      beforeEach(function() {
+        this.fs.read = sinon
+          .stub()
+          .withArgs(this.fd)
+          .yields(null, 100, new Buffer('hello not linearized 1'))
+        return this.OutputFileOptimiser.checkIfPDFIsOptimised(
+          this.src,
+          this.callback
+        )
+      })
+
+      it('should open the file', function() {
+        return this.fs.open.calledWith(this.src, 'r').should.equal(true)
+      })
+
+      it('should read the header', function() {
+        return this.fs.read.calledWith(this.fd).should.equal(true)
+      })
+
+      it('should close the file', function() {
+        return this.fs.close.calledWith(this.fd).should.equal(true)
+      })
+
+      return it('should call the callback with a false result', function() {
+        return this.callback.calledWith(null, false).should.equal(true)
+      })
+    })
+  })
+})

+ 78 - 67
services/clsi/test/unit/js/ProjectPersistenceManagerTests.js

@@ -11,79 +11,90 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/ProjectPersistenceManager');
-const tk = require("timekeeper");
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/ProjectPersistenceManager'
+)
+const tk = require('timekeeper')
 
-describe("ProjectPersistenceManager", function() {
-	beforeEach(function() {
-		this.ProjectPersistenceManager = SandboxedModule.require(modulePath, { requires: {
-			"./UrlCache": (this.UrlCache = {}),
-			"./CompileManager": (this.CompileManager = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub() }),
-			"./db": (this.db = {})
-		}
-	});
-		this.callback = sinon.stub();
-		this.project_id = "project-id-123";
-		return this.user_id = "1234";
-	});
+describe('ProjectPersistenceManager', function() {
+  beforeEach(function() {
+    this.ProjectPersistenceManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './UrlCache': (this.UrlCache = {}),
+        './CompileManager': (this.CompileManager = {}),
+        'logger-sharelatex': (this.logger = { log: sinon.stub() }),
+        './db': (this.db = {})
+      }
+    })
+    this.callback = sinon.stub()
+    this.project_id = 'project-id-123'
+    return (this.user_id = '1234')
+  })
 
-	describe("clearExpiredProjects", function() {
-		beforeEach(function() {
-			this.project_ids = [
-				"project-id-1",
-				"project-id-2"
-			];
-			this.ProjectPersistenceManager._findExpiredProjectIds = sinon.stub().callsArgWith(0, null, this.project_ids);
-			this.ProjectPersistenceManager.clearProjectFromCache = sinon.stub().callsArg(1);
-			this.CompileManager.clearExpiredProjects = sinon.stub().callsArg(1);
-			return this.ProjectPersistenceManager.clearExpiredProjects(this.callback);
-		});
+  describe('clearExpiredProjects', function() {
+    beforeEach(function() {
+      this.project_ids = ['project-id-1', 'project-id-2']
+      this.ProjectPersistenceManager._findExpiredProjectIds = sinon
+        .stub()
+        .callsArgWith(0, null, this.project_ids)
+      this.ProjectPersistenceManager.clearProjectFromCache = sinon
+        .stub()
+        .callsArg(1)
+      this.CompileManager.clearExpiredProjects = sinon.stub().callsArg(1)
+      return this.ProjectPersistenceManager.clearExpiredProjects(this.callback)
+    })
 
-		it("should clear each expired project", function() {
-			return Array.from(this.project_ids).map((project_id) =>
-				this.ProjectPersistenceManager.clearProjectFromCache
-					.calledWith(project_id)
-					.should.equal(true));
-		});
+    it('should clear each expired project', function() {
+      return Array.from(this.project_ids).map(project_id =>
+        this.ProjectPersistenceManager.clearProjectFromCache
+          .calledWith(project_id)
+          .should.equal(true)
+      )
+    })
 
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
 
-	return describe("clearProject", function() {
-		beforeEach(function() {
-			this.ProjectPersistenceManager._clearProjectFromDatabase = sinon.stub().callsArg(1);
-			this.UrlCache.clearProject = sinon.stub().callsArg(1);
-			this.CompileManager.clearProject = sinon.stub().callsArg(2);
-			return this.ProjectPersistenceManager.clearProject(this.project_id, this.user_id, this.callback);
-		});
+  return describe('clearProject', function() {
+    beforeEach(function() {
+      this.ProjectPersistenceManager._clearProjectFromDatabase = sinon
+        .stub()
+        .callsArg(1)
+      this.UrlCache.clearProject = sinon.stub().callsArg(1)
+      this.CompileManager.clearProject = sinon.stub().callsArg(2)
+      return this.ProjectPersistenceManager.clearProject(
+        this.project_id,
+        this.user_id,
+        this.callback
+      )
+    })
 
-		it("should clear the project from the database", function() {
-			return this.ProjectPersistenceManager._clearProjectFromDatabase
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
+    it('should clear the project from the database', function() {
+      return this.ProjectPersistenceManager._clearProjectFromDatabase
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
 
-		it("should clear all the cached Urls for the project", function() {
-			return this.UrlCache.clearProject
-				.calledWith(this.project_id)
-				.should.equal(true);
-		});
+    it('should clear all the cached Urls for the project', function() {
+      return this.UrlCache.clearProject
+        .calledWith(this.project_id)
+        .should.equal(true)
+    })
 
-		it("should clear the project compile folder", function() {
-			return this.CompileManager.clearProject
-				.calledWith(this.project_id, this.user_id)
-				.should.equal(true);
-		});
+    it('should clear the project compile folder', function() {
+      return this.CompileManager.clearProject
+        .calledWith(this.project_id, this.user_id)
+        .should.equal(true)
+    })
 
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
-			
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 409 - 375
services/clsi/test/unit/js/RequestParserTests.js

@@ -9,378 +9,412 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const { expect } = require('chai');
-const modulePath = require('path').join(__dirname, '../../../app/js/RequestParser');
-const tk = require("timekeeper");
-
-describe("RequestParser", function() {
-	beforeEach(function() {
-		tk.freeze();
-		this.callback = sinon.stub();
-		this.validResource = {
-			path: "main.tex",
-			date: "12:00 01/02/03",
-			content: "Hello world"
-		};
-		this.validRequest = {
-			compile: {
-				token: "token-123",
-				options: {
-					imageName: "basicImageName/here:2017-1",
-					compiler: "pdflatex",
-					timeout:  42
-				},
-				resources: []
-			}
-		};
-		return this.RequestParser = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex": (this.settings = {})
-		}
-	});});
-
-	afterEach(function() { return tk.reset(); });
-
-	describe("without a top level object", function() {
-		beforeEach(function() {
-			return this.RequestParser.parse([], this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("top level object should have a compile attribute")
-				.should.equal(true);
-		});
-	});
-
-	describe("without a compile attribute", function() {
-		beforeEach(function() {
-			return this.RequestParser.parse({}, this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("top level object should have a compile attribute")
-				.should.equal(true);
-		});
-	});
-
-	describe("without a valid compiler", function() {
-		beforeEach(function() {
-			this.validRequest.compile.options.compiler = "not-a-compiler";
-			return this.RequestParser.parse(this.validRequest, this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("compiler attribute should be one of: pdflatex, latex, xelatex, lualatex")
-				.should.equal(true);
-		});
-	});
-
-	describe("without a compiler specified", function() {
-		beforeEach(function() {
-			delete this.validRequest.compile.options.compiler;
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the compiler to pdflatex by default", function() {
-			return this.data.compiler.should.equal("pdflatex");
-		});
-	});
-
-	describe("with imageName set", function() {
-		beforeEach(function() {
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the imageName", function() {
-			return this.data.imageName.should.equal("basicImageName/here:2017-1");
-		});
-	});
-
-	describe("with flags set", function() {
-		beforeEach(function() {
-			this.validRequest.compile.options.flags = ["-file-line-error"];
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the flags attribute", function() {
-			return expect(this.data.flags).to.deep.equal(["-file-line-error"]);
-	});
-});
-
-	describe("with flags not specified", function() {
-		beforeEach(function() {
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("it should have an empty flags list", function() {
-			return expect(this.data.flags).to.deep.equal([]);
-	});
-});
-
-	describe("without a timeout specified", function() {
-		beforeEach(function() {
-			delete this.validRequest.compile.options.timeout;
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the timeout to MAX_TIMEOUT", function() {
-			return this.data.timeout.should.equal(this.RequestParser.MAX_TIMEOUT * 1000);
-		});
-	});
-
-	describe("with a timeout larger than the maximum", function() {
-		beforeEach(function() {
-			this.validRequest.compile.options.timeout = this.RequestParser.MAX_TIMEOUT + 1;
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the timeout to MAX_TIMEOUT", function() {
-			return this.data.timeout.should.equal(this.RequestParser.MAX_TIMEOUT * 1000);
-		});
-	});
-
-	describe("with a timeout", function() {
-		beforeEach(function() {
-			return this.RequestParser.parse(this.validRequest, (error, data) => {
-				this.data = data;
-				
-		});
-		});
-
-		return it("should set the timeout (in milliseconds)", function() {
-			return this.data.timeout.should.equal(this.validRequest.compile.options.timeout * 1000);
-		});
-	});
-
-	describe("with a resource without a path", function() {
-		beforeEach(function() {
-			delete this.validResource.path;
-			this.validRequest.compile.resources.push(this.validResource);
-			return this.RequestParser.parse(this.validRequest, this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("all resources should have a path attribute")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a resource with a path", function() {
-		beforeEach(function() {
-			this.validResource.path = (this.path = "test.tex");
-			this.validRequest.compile.resources.push(this.validResource);
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return the path in the parsed response", function() {
-			return this.data.resources[0].path.should.equal(this.path);
-		});
-	});
-
-	describe("with a resource with a malformed modified date", function() {
-		beforeEach(function() {
-			this.validResource.modified = "not-a-date";
-			this.validRequest.compile.resources.push(this.validResource);
-			return this.RequestParser.parse(this.validRequest, this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback
-				.calledWith(
-					"resource modified date could not be understood: "+
-					this.validResource.modified
-				)
-				.should.equal(true);
-		});
-	});
-
-	describe("with a resource with a valid date", function() {
-		beforeEach(function() {
-			this.date = "12:00 01/02/03";
-			this.validResource.modified = this.date;
-			this.validRequest.compile.resources.push(this.validResource);
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return the date as a Javascript Date object", function() {
-			(this.data.resources[0].modified instanceof Date).should.equal(true);
-			return this.data.resources[0].modified.getTime().should.equal(Date.parse(this.date));
-		});
-	});
-
-	describe("with a resource without either a content or URL attribute", function() {
-		beforeEach(function() {
-			delete this.validResource.url;
-			delete this.validResource.content;
-			this.validRequest.compile.resources.push(this.validResource);
-			return this.RequestParser.parse(this.validRequest, this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("all resources should have either a url or content attribute")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a resource where the content is not a string", function() {
-		beforeEach(function() {
-			this.validResource.content = [];
-			this.validRequest.compile.resources.push(this.validResource);
-			return this.RequestParser.parse((this.validRequest), this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("content attribute should be a string")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a resource where the url is not a string", function() {
-		beforeEach(function() {
-			this.validResource.url = [];
-			this.validRequest.compile.resources.push(this.validResource);
-			return this.RequestParser.parse((this.validRequest), this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("url attribute should be a string")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a resource with a url", function() {
-		beforeEach(function() {
-			this.validResource.url = (this.url = "www.example.com");
-			this.validRequest.compile.resources.push(this.validResource);
-			this.RequestParser.parse((this.validRequest), this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return the url in the parsed response", function() {
-			return this.data.resources[0].url.should.equal(this.url);
-		});
-	});
-
-	describe("with a resource with a content attribute", function() {
-		beforeEach(function() {
-			this.validResource.content = (this.content = "Hello world");
-			this.validRequest.compile.resources.push(this.validResource);
-			this.RequestParser.parse((this.validRequest), this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return the content in the parsed response", function() {
-			return this.data.resources[0].content.should.equal(this.content);
-		});
-	});
-
-	describe("without a root resource path", function() {
-		beforeEach(function() {
-			delete this.validRequest.compile.rootResourcePath;
-			this.RequestParser.parse((this.validRequest), this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should set the root resource path to 'main.tex' by default", function() {
-			return this.data.rootResourcePath.should.equal("main.tex");
-		});
-	});
-
-	describe("with a root resource path", function() {
-		beforeEach(function() {
-			this.validRequest.compile.rootResourcePath = (this.path = "test.tex");
-			this.RequestParser.parse((this.validRequest), this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return the root resource path in the parsed response", function() {
-			return this.data.rootResourcePath.should.equal(this.path);
-		});
-	});
-
-	describe("with a root resource path that is not a string", function() {
-		beforeEach(function() {
-			this.validRequest.compile.rootResourcePath = [];
-			return this.RequestParser.parse((this.validRequest), this.callback);
-		});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("rootResourcePath attribute should be a string")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a root resource path that needs escaping", function() {
-		beforeEach(function() {
-			this.badPath = "`rm -rf foo`.tex";
-			this.goodPath = "rm -rf foo.tex";
-			this.validRequest.compile.rootResourcePath = this.badPath;
-			this.validRequest.compile.resources.push({
-				path: this.badPath,
-				date: "12:00 01/02/03",
-				content: "Hello world"
-			});
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		it("should return the escaped resource", function() {
-			return this.data.rootResourcePath.should.equal(this.goodPath);
-		});
-
-		return it("should also escape the resource path", function() {
-			return this.data.resources[0].path.should.equal(this.goodPath);
-		});
-	});
-
-	describe("with a root resource path that has a relative path", function() {
-		beforeEach(function() {
-			this.validRequest.compile.rootResourcePath = "foo/../../bar.tex";
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("relative path in root resource")
-				.should.equal(true);
-		});
-	});
-
-	describe("with a root resource path that has unescaped + relative path", function() {
-		beforeEach(function() {
-			this.validRequest.compile.rootResourcePath = "foo/#../bar.tex";
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("relative path in root resource")
-				.should.equal(true);
-		});
-	});
-
-	return describe("with an unknown syncType", function() {
-		beforeEach(function() {
-			this.validRequest.compile.options.syncType = "unexpected";
-			this.RequestParser.parse(this.validRequest, this.callback);
-			return this.data = this.callback.args[0][1];});
-
-		return it("should return an error", function() {
-			return this.callback.calledWith("syncType attribute should be one of: full, incremental")
-				.should.equal(true);
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const { expect } = require('chai')
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/RequestParser'
+)
+const tk = require('timekeeper')
+
+describe('RequestParser', function() {
+  beforeEach(function() {
+    tk.freeze()
+    this.callback = sinon.stub()
+    this.validResource = {
+      path: 'main.tex',
+      date: '12:00 01/02/03',
+      content: 'Hello world'
+    }
+    this.validRequest = {
+      compile: {
+        token: 'token-123',
+        options: {
+          imageName: 'basicImageName/here:2017-1',
+          compiler: 'pdflatex',
+          timeout: 42
+        },
+        resources: []
+      }
+    }
+    return (this.RequestParser = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': (this.settings = {})
+      }
+    }))
+  })
+
+  afterEach(function() {
+    return tk.reset()
+  })
+
+  describe('without a top level object', function() {
+    beforeEach(function() {
+      return this.RequestParser.parse([], this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('top level object should have a compile attribute')
+        .should.equal(true)
+    })
+  })
+
+  describe('without a compile attribute', function() {
+    beforeEach(function() {
+      return this.RequestParser.parse({}, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('top level object should have a compile attribute')
+        .should.equal(true)
+    })
+  })
+
+  describe('without a valid compiler', function() {
+    beforeEach(function() {
+      this.validRequest.compile.options.compiler = 'not-a-compiler'
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith(
+          'compiler attribute should be one of: pdflatex, latex, xelatex, lualatex'
+        )
+        .should.equal(true)
+    })
+  })
+
+  describe('without a compiler specified', function() {
+    beforeEach(function() {
+      delete this.validRequest.compile.options.compiler
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the compiler to pdflatex by default', function() {
+      return this.data.compiler.should.equal('pdflatex')
+    })
+  })
+
+  describe('with imageName set', function() {
+    beforeEach(function() {
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the imageName', function() {
+      return this.data.imageName.should.equal('basicImageName/here:2017-1')
+    })
+  })
+
+  describe('with flags set', function() {
+    beforeEach(function() {
+      this.validRequest.compile.options.flags = ['-file-line-error']
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the flags attribute', function() {
+      return expect(this.data.flags).to.deep.equal(['-file-line-error'])
+    })
+  })
+
+  describe('with flags not specified', function() {
+    beforeEach(function() {
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('it should have an empty flags list', function() {
+      return expect(this.data.flags).to.deep.equal([])
+    })
+  })
+
+  describe('without a timeout specified', function() {
+    beforeEach(function() {
+      delete this.validRequest.compile.options.timeout
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the timeout to MAX_TIMEOUT', function() {
+      return this.data.timeout.should.equal(
+        this.RequestParser.MAX_TIMEOUT * 1000
+      )
+    })
+  })
+
+  describe('with a timeout larger than the maximum', function() {
+    beforeEach(function() {
+      this.validRequest.compile.options.timeout =
+        this.RequestParser.MAX_TIMEOUT + 1
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the timeout to MAX_TIMEOUT', function() {
+      return this.data.timeout.should.equal(
+        this.RequestParser.MAX_TIMEOUT * 1000
+      )
+    })
+  })
+
+  describe('with a timeout', function() {
+    beforeEach(function() {
+      return this.RequestParser.parse(this.validRequest, (error, data) => {
+        this.data = data
+      })
+    })
+
+    return it('should set the timeout (in milliseconds)', function() {
+      return this.data.timeout.should.equal(
+        this.validRequest.compile.options.timeout * 1000
+      )
+    })
+  })
+
+  describe('with a resource without a path', function() {
+    beforeEach(function() {
+      delete this.validResource.path
+      this.validRequest.compile.resources.push(this.validResource)
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('all resources should have a path attribute')
+        .should.equal(true)
+    })
+  })
+
+  describe('with a resource with a path', function() {
+    beforeEach(function() {
+      this.validResource.path = this.path = 'test.tex'
+      this.validRequest.compile.resources.push(this.validResource)
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return the path in the parsed response', function() {
+      return this.data.resources[0].path.should.equal(this.path)
+    })
+  })
+
+  describe('with a resource with a malformed modified date', function() {
+    beforeEach(function() {
+      this.validResource.modified = 'not-a-date'
+      this.validRequest.compile.resources.push(this.validResource)
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith(
+          'resource modified date could not be understood: ' +
+            this.validResource.modified
+        )
+        .should.equal(true)
+    })
+  })
+
+  describe('with a resource with a valid date', function() {
+    beforeEach(function() {
+      this.date = '12:00 01/02/03'
+      this.validResource.modified = this.date
+      this.validRequest.compile.resources.push(this.validResource)
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return the date as a Javascript Date object', function() {
+      ;(this.data.resources[0].modified instanceof Date).should.equal(true)
+      return this.data.resources[0].modified
+        .getTime()
+        .should.equal(Date.parse(this.date))
+    })
+  })
+
+  describe('with a resource without either a content or URL attribute', function() {
+    beforeEach(function() {
+      delete this.validResource.url
+      delete this.validResource.content
+      this.validRequest.compile.resources.push(this.validResource)
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith(
+          'all resources should have either a url or content attribute'
+        )
+        .should.equal(true)
+    })
+  })
+
+  describe('with a resource where the content is not a string', function() {
+    beforeEach(function() {
+      this.validResource.content = []
+      this.validRequest.compile.resources.push(this.validResource)
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('content attribute should be a string')
+        .should.equal(true)
+    })
+  })
+
+  describe('with a resource where the url is not a string', function() {
+    beforeEach(function() {
+      this.validResource.url = []
+      this.validRequest.compile.resources.push(this.validResource)
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('url attribute should be a string')
+        .should.equal(true)
+    })
+  })
+
+  describe('with a resource with a url', function() {
+    beforeEach(function() {
+      this.validResource.url = this.url = 'www.example.com'
+      this.validRequest.compile.resources.push(this.validResource)
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return the url in the parsed response', function() {
+      return this.data.resources[0].url.should.equal(this.url)
+    })
+  })
+
+  describe('with a resource with a content attribute', function() {
+    beforeEach(function() {
+      this.validResource.content = this.content = 'Hello world'
+      this.validRequest.compile.resources.push(this.validResource)
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return the content in the parsed response', function() {
+      return this.data.resources[0].content.should.equal(this.content)
+    })
+  })
+
+  describe('without a root resource path', function() {
+    beforeEach(function() {
+      delete this.validRequest.compile.rootResourcePath
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it("should set the root resource path to 'main.tex' by default", function() {
+      return this.data.rootResourcePath.should.equal('main.tex')
+    })
+  })
+
+  describe('with a root resource path', function() {
+    beforeEach(function() {
+      this.validRequest.compile.rootResourcePath = this.path = 'test.tex'
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return the root resource path in the parsed response', function() {
+      return this.data.rootResourcePath.should.equal(this.path)
+    })
+  })
+
+  describe('with a root resource path that is not a string', function() {
+    beforeEach(function() {
+      this.validRequest.compile.rootResourcePath = []
+      return this.RequestParser.parse(this.validRequest, this.callback)
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('rootResourcePath attribute should be a string')
+        .should.equal(true)
+    })
+  })
+
+  describe('with a root resource path that needs escaping', function() {
+    beforeEach(function() {
+      this.badPath = '`rm -rf foo`.tex'
+      this.goodPath = 'rm -rf foo.tex'
+      this.validRequest.compile.rootResourcePath = this.badPath
+      this.validRequest.compile.resources.push({
+        path: this.badPath,
+        date: '12:00 01/02/03',
+        content: 'Hello world'
+      })
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    it('should return the escaped resource', function() {
+      return this.data.rootResourcePath.should.equal(this.goodPath)
+    })
+
+    return it('should also escape the resource path', function() {
+      return this.data.resources[0].path.should.equal(this.goodPath)
+    })
+  })
+
+  describe('with a root resource path that has a relative path', function() {
+    beforeEach(function() {
+      this.validRequest.compile.rootResourcePath = 'foo/../../bar.tex'
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('relative path in root resource')
+        .should.equal(true)
+    })
+  })
+
+  describe('with a root resource path that has unescaped + relative path', function() {
+    beforeEach(function() {
+      this.validRequest.compile.rootResourcePath = 'foo/#../bar.tex'
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('relative path in root resource')
+        .should.equal(true)
+    })
+  })
+
+  return describe('with an unknown syncType', function() {
+    beforeEach(function() {
+      this.validRequest.compile.options.syncType = 'unexpected'
+      this.RequestParser.parse(this.validRequest, this.callback)
+      return (this.data = this.callback.args[0][1])
+    })
+
+    return it('should return an error', function() {
+      return this.callback
+        .calledWith('syncType attribute should be one of: full, incremental')
+        .should.equal(true)
+    })
+  })
+})

+ 197 - 142
services/clsi/test/unit/js/ResourceStateManagerTests.js

@@ -9,145 +9,200 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-const should = require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/ResourceStateManager');
-const Path = require("path");
-const Errors = require("../../../app/js/Errors");
-
-describe("ResourceStateManager", function() {
-	beforeEach(function() {
-		this.ResourceStateManager = SandboxedModule.require(modulePath, { requires: {
-			"fs": (this.fs = {}),
-			"logger-sharelatex": {log: sinon.stub(), err: sinon.stub()},
-			"./SafeReader": (this.SafeReader = {})
-		}
-	});
-		this.basePath = "/path/to/write/files/to";
-		this.resources = [
-			{path: "resource-1-mock"},
-			{path: "resource-2-mock"},
-			{path: "resource-3-mock"}
-		];
-		this.state = "1234567890";
-		this.resourceFileName = `${this.basePath}/.project-sync-state`;
-		this.resourceFileContents = `${this.resources[0].path}\n${this.resources[1].path}\n${this.resources[2].path}\nstateHash:${this.state}`;
-		return this.callback = sinon.stub();
-	});
-
-	describe("saveProjectState", function() {
-		beforeEach(function() {
-			return this.fs.writeFile = sinon.stub().callsArg(2);
-		});
-
-		describe("when the state is specified", function() {
-			beforeEach(function() {
-				return this.ResourceStateManager.saveProjectState(this.state, this.resources, this.basePath, this.callback);
-			});
-
-			it("should write the resource list to disk", function() {
-				return this.fs.writeFile
-					.calledWith(this.resourceFileName, this.resourceFileContents)
-					.should.equal(true);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("when the state is undefined", function() {
-			beforeEach(function() {
-				this.state = undefined;
-				this.fs.unlink = sinon.stub().callsArg(1);
-				return this.ResourceStateManager.saveProjectState(this.state, this.resources, this.basePath, this.callback);
-			});
-
-			it("should unlink the resource file", function() {
-				return this.fs.unlink
-					.calledWith(this.resourceFileName)
-					.should.equal(true);
-			});
-
-			it("should not write the resource list to disk", function() {
-				return this.fs.writeFile.called.should.equal(false);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-	});
-
-	describe("checkProjectStateMatches", function() {
-
-		describe("when the state matches", function() {
-			beforeEach(function() {
-				this.SafeReader.readFile = sinon.stub().callsArgWith(3, null, this.resourceFileContents);
-				return this.ResourceStateManager.checkProjectStateMatches(this.state, this.basePath, this.callback);
-			});
-
-			it("should read the resource file", function() {
-				return this.SafeReader.readFile
-					.calledWith(this.resourceFileName)
-					.should.equal(true);
-			});
-
-			return it("should call the callback with the results", function() {
-				return this.callback.calledWithMatch(null, this.resources).should.equal(true);
-			});
-		});
-
-		return describe("when the state does not match", function() {
-			beforeEach(function() {
-				this.SafeReader.readFile = sinon.stub().callsArgWith(3, null, this.resourceFileContents);
-				return this.ResourceStateManager.checkProjectStateMatches("not-the-original-state", this.basePath, this.callback);
-			});
-
-			return it("should call the callback with an error", function() {
-				const error = new Errors.FilesOutOfSyncError("invalid state for incremental update");
-				return this.callback.calledWith(error).should.equal(true);
-			});
-		});
-	});
-
-	return describe("checkResourceFiles", function() {
-		describe("when all the files are present", function() {
-			beforeEach(function() {
-				this.allFiles = [ this.resources[0].path, this.resources[1].path, this.resources[2].path];
-				return this.ResourceStateManager.checkResourceFiles(this.resources, this.allFiles, this.basePath, this.callback);
-			});
-
-			return it("should call the callback", function() {
-				return this.callback.calledWithExactly().should.equal(true);
-			});
-		});
-
-		describe("when there is a missing file", function() {
-			beforeEach(function() {
-				this.allFiles = [ this.resources[0].path, this.resources[1].path];
-				this.fs.stat = sinon.stub().callsArgWith(1, new Error());
-				return this.ResourceStateManager.checkResourceFiles(this.resources, this.allFiles, this.basePath, this.callback);
-			});
-
-			return it("should call the callback with an error", function() {
-				const error = new Errors.FilesOutOfSyncError("resource files missing in incremental update");
-				return this.callback.calledWith(error).should.equal(true);
-			});
-		});
-
-		return describe("when a resource contains a relative path", function() {
-			beforeEach(function() {
-				this.resources[0].path = "../foo/bar.tex";
-				this.allFiles = [ this.resources[0].path, this.resources[1].path, this.resources[2].path];
-				return this.ResourceStateManager.checkResourceFiles(this.resources, this.allFiles, this.basePath, this.callback);
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback.calledWith(new Error("relative path in resource file list")).should.equal(true);
-			});
-		});
-	});
-});
-
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+const should = require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/ResourceStateManager'
+)
+const Path = require('path')
+const Errors = require('../../../app/js/Errors')
+
+describe('ResourceStateManager', function() {
+  beforeEach(function() {
+    this.ResourceStateManager = SandboxedModule.require(modulePath, {
+      requires: {
+        fs: (this.fs = {}),
+        'logger-sharelatex': { log: sinon.stub(), err: sinon.stub() },
+        './SafeReader': (this.SafeReader = {})
+      }
+    })
+    this.basePath = '/path/to/write/files/to'
+    this.resources = [
+      { path: 'resource-1-mock' },
+      { path: 'resource-2-mock' },
+      { path: 'resource-3-mock' }
+    ]
+    this.state = '1234567890'
+    this.resourceFileName = `${this.basePath}/.project-sync-state`
+    this.resourceFileContents = `${this.resources[0].path}\n${this.resources[1].path}\n${this.resources[2].path}\nstateHash:${this.state}`
+    return (this.callback = sinon.stub())
+  })
+
+  describe('saveProjectState', function() {
+    beforeEach(function() {
+      return (this.fs.writeFile = sinon.stub().callsArg(2))
+    })
+
+    describe('when the state is specified', function() {
+      beforeEach(function() {
+        return this.ResourceStateManager.saveProjectState(
+          this.state,
+          this.resources,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should write the resource list to disk', function() {
+        return this.fs.writeFile
+          .calledWith(this.resourceFileName, this.resourceFileContents)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('when the state is undefined', function() {
+      beforeEach(function() {
+        this.state = undefined
+        this.fs.unlink = sinon.stub().callsArg(1)
+        return this.ResourceStateManager.saveProjectState(
+          this.state,
+          this.resources,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should unlink the resource file', function() {
+        return this.fs.unlink
+          .calledWith(this.resourceFileName)
+          .should.equal(true)
+      })
+
+      it('should not write the resource list to disk', function() {
+        return this.fs.writeFile.called.should.equal(false)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+  })
+
+  describe('checkProjectStateMatches', function() {
+    describe('when the state matches', function() {
+      beforeEach(function() {
+        this.SafeReader.readFile = sinon
+          .stub()
+          .callsArgWith(3, null, this.resourceFileContents)
+        return this.ResourceStateManager.checkProjectStateMatches(
+          this.state,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should read the resource file', function() {
+        return this.SafeReader.readFile
+          .calledWith(this.resourceFileName)
+          .should.equal(true)
+      })
+
+      return it('should call the callback with the results', function() {
+        return this.callback
+          .calledWithMatch(null, this.resources)
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the state does not match', function() {
+      beforeEach(function() {
+        this.SafeReader.readFile = sinon
+          .stub()
+          .callsArgWith(3, null, this.resourceFileContents)
+        return this.ResourceStateManager.checkProjectStateMatches(
+          'not-the-original-state',
+          this.basePath,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error', function() {
+        const error = new Errors.FilesOutOfSyncError(
+          'invalid state for incremental update'
+        )
+        return this.callback.calledWith(error).should.equal(true)
+      })
+    })
+  })
+
+  return describe('checkResourceFiles', function() {
+    describe('when all the files are present', function() {
+      beforeEach(function() {
+        this.allFiles = [
+          this.resources[0].path,
+          this.resources[1].path,
+          this.resources[2].path
+        ]
+        return this.ResourceStateManager.checkResourceFiles(
+          this.resources,
+          this.allFiles,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.calledWithExactly().should.equal(true)
+      })
+    })
+
+    describe('when there is a missing file', function() {
+      beforeEach(function() {
+        this.allFiles = [this.resources[0].path, this.resources[1].path]
+        this.fs.stat = sinon.stub().callsArgWith(1, new Error())
+        return this.ResourceStateManager.checkResourceFiles(
+          this.resources,
+          this.allFiles,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error', function() {
+        const error = new Errors.FilesOutOfSyncError(
+          'resource files missing in incremental update'
+        )
+        return this.callback.calledWith(error).should.equal(true)
+      })
+    })
+
+    return describe('when a resource contains a relative path', function() {
+      beforeEach(function() {
+        this.resources[0].path = '../foo/bar.tex'
+        this.allFiles = [
+          this.resources[0].path,
+          this.resources[1].path,
+          this.resources[2].path
+        ]
+        return this.ResourceStateManager.checkResourceFiles(
+          this.resources,
+          this.allFiles,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback
+          .calledWith(new Error('relative path in resource file list'))
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 488 - 402
services/clsi/test/unit/js/ResourceWriterTests.js

@@ -10,405 +10,491 @@
  * DS206: Consider reworking classes to avoid initClass
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-const should = require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/ResourceWriter');
-const path = require("path");
-
-describe("ResourceWriter", function() {
-	beforeEach(function() {
-		let Timer;
-		this.ResourceWriter = SandboxedModule.require(modulePath, { requires: {
-			"fs": (this.fs = {
-				mkdir: sinon.stub().callsArg(1),
-				unlink: sinon.stub().callsArg(1)
-			}),
-			"./ResourceStateManager": (this.ResourceStateManager = {}),
-			"wrench": (this.wrench = {}),
-			"./UrlCache" : (this.UrlCache = {}),
-			"mkdirp" : (this.mkdirp = sinon.stub().callsArg(1)),
-			"./OutputFileFinder": (this.OutputFileFinder = {}),
-			"logger-sharelatex": {log: sinon.stub(), err: sinon.stub()},
-			"./Metrics": (this.Metrics = {
-				Timer: (Timer = (function() {
-					Timer = class Timer {
-						static initClass() {
-							this.prototype.done = sinon.stub();
-						}
-					};
-					Timer.initClass();
-					return Timer;
-				})())
-			})
-		}
-	}
-		);
-		this.project_id = "project-id-123";
-		this.basePath = "/path/to/write/files/to";
-		return this.callback = sinon.stub();
-	});
-
-	describe("syncResourcesToDisk on a full request", function() {
-		beforeEach(function() {
-			this.resources = [
-				"resource-1-mock",
-				"resource-2-mock",
-				"resource-3-mock"
-			];
-			this.ResourceWriter._writeResourceToDisk = sinon.stub().callsArg(3);
-			this.ResourceWriter._removeExtraneousFiles = sinon.stub().callsArg(2);
-			this.ResourceStateManager.saveProjectState = sinon.stub().callsArg(3);
-			return this.ResourceWriter.syncResourcesToDisk({
-				project_id: this.project_id,
-				syncState: (this.syncState = "0123456789abcdef"),
-				resources: this.resources
-			}, this.basePath, this.callback);
-		});
-
-		it("should remove old files", function() {
-			return this.ResourceWriter._removeExtraneousFiles
-				.calledWith(this.resources, this.basePath)
-				.should.equal(true);
-		});
-
-		it("should write each resource to disk", function() {
-			return Array.from(this.resources).map((resource) =>
-				this.ResourceWriter._writeResourceToDisk
-					.calledWith(this.project_id, resource, this.basePath)
-					.should.equal(true));
-		});
-
-		it("should store the sync state and resource list", function() {
-			return this.ResourceStateManager.saveProjectState
-				.calledWith(this.syncState, this.resources, this.basePath)
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("syncResourcesToDisk on an incremental update", function() {
-		beforeEach(function() {
-			this.resources = [
-				"resource-1-mock"
-			];
-			this.ResourceWriter._writeResourceToDisk = sinon.stub().callsArg(3);
-			this.ResourceWriter._removeExtraneousFiles = sinon.stub().callsArgWith(2, null, (this.outputFiles = []), (this.allFiles = []));
-			this.ResourceStateManager.checkProjectStateMatches = sinon.stub().callsArgWith(2, null, this.resources);
-			this.ResourceStateManager.saveProjectState = sinon.stub().callsArg(3);
-			this.ResourceStateManager.checkResourceFiles = sinon.stub().callsArg(3);
-			return this.ResourceWriter.syncResourcesToDisk({
-				project_id: this.project_id,
-				syncType: "incremental",
-				syncState: (this.syncState = "1234567890abcdef"),
-				resources: this.resources
-			}, this.basePath, this.callback);
-		});
-
-		it("should check the sync state matches", function() {
-			return this.ResourceStateManager.checkProjectStateMatches
-				.calledWith(this.syncState, this.basePath)
-				.should.equal(true);
-		});
-
-		it("should remove old files", function() {
-			return this.ResourceWriter._removeExtraneousFiles
-				.calledWith(this.resources, this.basePath)
-				.should.equal(true);
-		});
-
-		it("should check each resource exists", function() {
-			return this.ResourceStateManager.checkResourceFiles
-				.calledWith(this.resources, this.allFiles, this.basePath)
-				.should.equal(true);
-		});
-
-		it("should write each resource to disk", function() {
-			return Array.from(this.resources).map((resource) =>
-				this.ResourceWriter._writeResourceToDisk
-					.calledWith(this.project_id, resource, this.basePath)
-					.should.equal(true));
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("syncResourcesToDisk on an incremental update when the state does not match", function() {
-		beforeEach(function() {
-			this.resources = [
-				"resource-1-mock"
-			];
-			this.ResourceStateManager.checkProjectStateMatches = sinon.stub().callsArgWith(2, (this.error = new Error()));
-			return this.ResourceWriter.syncResourcesToDisk({
-				project_id: this.project_id,
-				syncType: "incremental",
-				syncState: (this.syncState = "1234567890abcdef"),
-				resources: this.resources
-			}, this.basePath, this.callback);
-		});
-
-		it("should check whether the sync state matches", function() {
-			return this.ResourceStateManager.checkProjectStateMatches
-				.calledWith(this.syncState, this.basePath)
-				.should.equal(true);
-		});
-
-		return it("should call the callback with an error", function() {
-			return this.callback.calledWith(this.error).should.equal(true);
-		});
-	});
-
-
-	describe("_removeExtraneousFiles", function() {
-		beforeEach(function() {
-			this.output_files = [{
-				path: "output.pdf",
-				type: "pdf"
-			}, {
-				path: "extra/file.tex",
-				type: "tex"
-			}, {
-				path: "extra.aux",
-				type: "aux"
-			}, {
-				path: "cache/_chunk1"
-			},{
-				path: "figures/image-eps-converted-to.pdf",
-				type: "pdf"
-			},{
-				path: "foo/main-figure0.md5",
-				type: "md5"
-			}, {
-				path: "foo/main-figure0.dpth",
-				type: "dpth"
-			}, {
-				path: "foo/main-figure0.pdf",
-				type: "pdf"
-			}, {
-				path: "_minted-main/default-pyg-prefix.pygstyle",
-				type: "pygstyle"
-			}, {
-				path: "_minted-main/default.pygstyle",
-				type: "pygstyle"
-			}, {
-				path: "_minted-main/35E248B60965545BD232AE9F0FE9750D504A7AF0CD3BAA7542030FC560DFCC45.pygtex",
-				type: "pygtex"
-			}, {
-				path: "_markdown_main/30893013dec5d869a415610079774c2f.md.tex",
-				type: "tex"
-			}];
-			this.resources = "mock-resources";
-			this.OutputFileFinder.findOutputFiles = sinon.stub().callsArgWith(2, null, this.output_files);
-			this.ResourceWriter._deleteFileIfNotDirectory = sinon.stub().callsArg(1);
-			return this.ResourceWriter._removeExtraneousFiles(this.resources, this.basePath, this.callback);
-		});
-
-		it("should find the existing output files", function() {
-			return this.OutputFileFinder.findOutputFiles
-				.calledWith(this.resources, this.basePath)
-				.should.equal(true);
-		});
-
-		it("should delete the output files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "output.pdf"))
-				.should.equal(true);
-		});
-
-		it("should delete the extra files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "extra/file.tex"))
-				.should.equal(true);
-		});
-
-		it("should not delete the extra aux files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "extra.aux"))
-				.should.equal(false);
-		});
-		
-		it("should not delete the knitr cache file", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "cache/_chunk1"))
-				.should.equal(false);
-		});
-
-		it("should not delete the epstopdf converted files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "figures/image-eps-converted-to.pdf"))
-				.should.equal(false);
-		});
-
-		it("should not delete the tikz md5 files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "foo/main-figure0.md5"))
-				.should.equal(false);
-		});
-
-		it("should not delete the tikz dpth files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "foo/main-figure0.dpth"))
-				.should.equal(false);
-		});
-
-		it("should not delete the tikz pdf files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "foo/main-figure0.pdf"))
-				.should.equal(false);
-		});
-
-		it("should not delete the minted pygstyle files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "_minted-main/default-pyg-prefix.pygstyle"))
-				.should.equal(false);
-		});
-
-		it("should not delete the minted default pygstyle files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "_minted-main/default.pygstyle"))
-				.should.equal(false);
-		});
-
-		it("should not delete the minted default pygtex files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "_minted-main/35E248B60965545BD232AE9F0FE9750D504A7AF0CD3BAA7542030FC560DFCC45.pygtex"))
-				.should.equal(false);
-		});
-
-		it("should not delete the markdown md.tex files", function() {
-			return this.ResourceWriter._deleteFileIfNotDirectory
-				.calledWith(path.join(this.basePath, "_markdown_main/30893013dec5d869a415610079774c2f.md.tex"))
-				.should.equal(false);
-		});
-
-		it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-
-		return it("should time the request", function() {
-			return this.Metrics.Timer.prototype.done.called.should.equal(true);
-		});
-	});
-
-	describe("_writeResourceToDisk", function() {
-		describe("with a url based resource", function() {
-			beforeEach(function() {
-				this.resource = {
-					path: "main.tex",
-					url: "http://www.example.com/main.tex",
-					modified: Date.now()
-				};
-				this.UrlCache.downloadUrlToFile = sinon.stub().callsArgWith(4, "fake error downloading file");
-				return this.ResourceWriter._writeResourceToDisk(this.project_id, this.resource, this.basePath, this.callback);
-			});
-
-			it("should ensure the directory exists", function() {
-				return this.mkdirp
-					.calledWith(path.dirname(path.join(this.basePath, this.resource.path)))
-					.should.equal(true);
-			});
-
-			it("should write the URL from the cache", function() {
-				return this.UrlCache.downloadUrlToFile
-					.calledWith(this.project_id, this.resource.url, path.join(this.basePath, this.resource.path), this.resource.modified)
-					.should.equal(true);
-			});
-			
-			it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-
-			return it("should not return an error if the resource writer errored", function() {
-				return should.not.exist(this.callback.args[0][0]);
-		});
-	});
-
-		describe("with a content based resource", function() {
-			beforeEach(function() {
-				this.resource = {
-					path: "main.tex",
-					content: "Hello world"
-				};
-				this.fs.writeFile = sinon.stub().callsArg(2);
-				return this.ResourceWriter._writeResourceToDisk(this.project_id, this.resource, this.basePath, this.callback);
-			});
-
-			it("should ensure the directory exists", function() {
-				return this.mkdirp
-					.calledWith(path.dirname(path.join(this.basePath, this.resource.path)))
-					.should.equal(true);
-			});
-
-			it("should write the contents to disk", function() {
-				return this.fs.writeFile
-					.calledWith(path.join(this.basePath, this.resource.path), this.resource.content)
-					.should.equal(true);
-			});
-				
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		return describe("with a file path that breaks out of the root folder", function() {
-			beforeEach(function() {
-				this.resource = {
-					path: "../../main.tex",
-					content: "Hello world"
-				};
-				this.fs.writeFile = sinon.stub().callsArg(2);
-				return this.ResourceWriter._writeResourceToDisk(this.project_id, this.resource, this.basePath, this.callback);
-			});
-
-			it("should not write to disk", function() {
-				return this.fs.writeFile.called.should.equal(false);
-			});
-
-			return it("should return an error", function() {
-				return this.callback
-					.calledWith(new Error("resource path is outside root directory"))
-					.should.equal(true);
-			});
-		});
-	});
-			
-	return describe("checkPath", function() {
-		describe("with a valid path", function() {
-			beforeEach(function() {
-				return this.ResourceWriter.checkPath("foo", "bar", this.callback);
-			});
-
-			return it("should return the joined path", function() {
-				return this.callback.calledWith(null, "foo/bar")
-				.should.equal(true);
-			});
-		});
-
-		describe("with an invalid path", function() {
-			beforeEach(function() {
-				return this.ResourceWriter.checkPath("foo", "baz/../../bar", this.callback);
-			});
-
-			return it("should return an error", function() {
-				return this.callback.calledWith(new Error("resource path is outside root directory"))
-				.should.equal(true);
-			});
-		});
-
-		return describe("with another invalid path matching on a prefix", function() {
-			beforeEach(function() {
-				return this.ResourceWriter.checkPath("foo", "../foobar/baz", this.callback);
-			});
-
-			return it("should return an error", function() {
-				return this.callback.calledWith(new Error("resource path is outside root directory"))
-				.should.equal(true);
-			});
-		});
-	});
-});
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+const should = require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/ResourceWriter'
+)
+const path = require('path')
+
+describe('ResourceWriter', function() {
+  beforeEach(function() {
+    let Timer
+    this.ResourceWriter = SandboxedModule.require(modulePath, {
+      requires: {
+        fs: (this.fs = {
+          mkdir: sinon.stub().callsArg(1),
+          unlink: sinon.stub().callsArg(1)
+        }),
+        './ResourceStateManager': (this.ResourceStateManager = {}),
+        wrench: (this.wrench = {}),
+        './UrlCache': (this.UrlCache = {}),
+        mkdirp: (this.mkdirp = sinon.stub().callsArg(1)),
+        './OutputFileFinder': (this.OutputFileFinder = {}),
+        'logger-sharelatex': { log: sinon.stub(), err: sinon.stub() },
+        './Metrics': (this.Metrics = {
+          Timer: (Timer = (function() {
+            Timer = class Timer {
+              static initClass() {
+                this.prototype.done = sinon.stub()
+              }
+            }
+            Timer.initClass()
+            return Timer
+          })())
+        })
+      }
+    })
+    this.project_id = 'project-id-123'
+    this.basePath = '/path/to/write/files/to'
+    return (this.callback = sinon.stub())
+  })
+
+  describe('syncResourcesToDisk on a full request', function() {
+    beforeEach(function() {
+      this.resources = ['resource-1-mock', 'resource-2-mock', 'resource-3-mock']
+      this.ResourceWriter._writeResourceToDisk = sinon.stub().callsArg(3)
+      this.ResourceWriter._removeExtraneousFiles = sinon.stub().callsArg(2)
+      this.ResourceStateManager.saveProjectState = sinon.stub().callsArg(3)
+      return this.ResourceWriter.syncResourcesToDisk(
+        {
+          project_id: this.project_id,
+          syncState: (this.syncState = '0123456789abcdef'),
+          resources: this.resources
+        },
+        this.basePath,
+        this.callback
+      )
+    })
+
+    it('should remove old files', function() {
+      return this.ResourceWriter._removeExtraneousFiles
+        .calledWith(this.resources, this.basePath)
+        .should.equal(true)
+    })
+
+    it('should write each resource to disk', function() {
+      return Array.from(this.resources).map(resource =>
+        this.ResourceWriter._writeResourceToDisk
+          .calledWith(this.project_id, resource, this.basePath)
+          .should.equal(true)
+      )
+    })
+
+    it('should store the sync state and resource list', function() {
+      return this.ResourceStateManager.saveProjectState
+        .calledWith(this.syncState, this.resources, this.basePath)
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('syncResourcesToDisk on an incremental update', function() {
+    beforeEach(function() {
+      this.resources = ['resource-1-mock']
+      this.ResourceWriter._writeResourceToDisk = sinon.stub().callsArg(3)
+      this.ResourceWriter._removeExtraneousFiles = sinon
+        .stub()
+        .callsArgWith(2, null, (this.outputFiles = []), (this.allFiles = []))
+      this.ResourceStateManager.checkProjectStateMatches = sinon
+        .stub()
+        .callsArgWith(2, null, this.resources)
+      this.ResourceStateManager.saveProjectState = sinon.stub().callsArg(3)
+      this.ResourceStateManager.checkResourceFiles = sinon.stub().callsArg(3)
+      return this.ResourceWriter.syncResourcesToDisk(
+        {
+          project_id: this.project_id,
+          syncType: 'incremental',
+          syncState: (this.syncState = '1234567890abcdef'),
+          resources: this.resources
+        },
+        this.basePath,
+        this.callback
+      )
+    })
+
+    it('should check the sync state matches', function() {
+      return this.ResourceStateManager.checkProjectStateMatches
+        .calledWith(this.syncState, this.basePath)
+        .should.equal(true)
+    })
+
+    it('should remove old files', function() {
+      return this.ResourceWriter._removeExtraneousFiles
+        .calledWith(this.resources, this.basePath)
+        .should.equal(true)
+    })
+
+    it('should check each resource exists', function() {
+      return this.ResourceStateManager.checkResourceFiles
+        .calledWith(this.resources, this.allFiles, this.basePath)
+        .should.equal(true)
+    })
+
+    it('should write each resource to disk', function() {
+      return Array.from(this.resources).map(resource =>
+        this.ResourceWriter._writeResourceToDisk
+          .calledWith(this.project_id, resource, this.basePath)
+          .should.equal(true)
+      )
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('syncResourcesToDisk on an incremental update when the state does not match', function() {
+    beforeEach(function() {
+      this.resources = ['resource-1-mock']
+      this.ResourceStateManager.checkProjectStateMatches = sinon
+        .stub()
+        .callsArgWith(2, (this.error = new Error()))
+      return this.ResourceWriter.syncResourcesToDisk(
+        {
+          project_id: this.project_id,
+          syncType: 'incremental',
+          syncState: (this.syncState = '1234567890abcdef'),
+          resources: this.resources
+        },
+        this.basePath,
+        this.callback
+      )
+    })
+
+    it('should check whether the sync state matches', function() {
+      return this.ResourceStateManager.checkProjectStateMatches
+        .calledWith(this.syncState, this.basePath)
+        .should.equal(true)
+    })
+
+    return it('should call the callback with an error', function() {
+      return this.callback.calledWith(this.error).should.equal(true)
+    })
+  })
+
+  describe('_removeExtraneousFiles', function() {
+    beforeEach(function() {
+      this.output_files = [
+        {
+          path: 'output.pdf',
+          type: 'pdf'
+        },
+        {
+          path: 'extra/file.tex',
+          type: 'tex'
+        },
+        {
+          path: 'extra.aux',
+          type: 'aux'
+        },
+        {
+          path: 'cache/_chunk1'
+        },
+        {
+          path: 'figures/image-eps-converted-to.pdf',
+          type: 'pdf'
+        },
+        {
+          path: 'foo/main-figure0.md5',
+          type: 'md5'
+        },
+        {
+          path: 'foo/main-figure0.dpth',
+          type: 'dpth'
+        },
+        {
+          path: 'foo/main-figure0.pdf',
+          type: 'pdf'
+        },
+        {
+          path: '_minted-main/default-pyg-prefix.pygstyle',
+          type: 'pygstyle'
+        },
+        {
+          path: '_minted-main/default.pygstyle',
+          type: 'pygstyle'
+        },
+        {
+          path:
+            '_minted-main/35E248B60965545BD232AE9F0FE9750D504A7AF0CD3BAA7542030FC560DFCC45.pygtex',
+          type: 'pygtex'
+        },
+        {
+          path: '_markdown_main/30893013dec5d869a415610079774c2f.md.tex',
+          type: 'tex'
+        }
+      ]
+      this.resources = 'mock-resources'
+      this.OutputFileFinder.findOutputFiles = sinon
+        .stub()
+        .callsArgWith(2, null, this.output_files)
+      this.ResourceWriter._deleteFileIfNotDirectory = sinon.stub().callsArg(1)
+      return this.ResourceWriter._removeExtraneousFiles(
+        this.resources,
+        this.basePath,
+        this.callback
+      )
+    })
+
+    it('should find the existing output files', function() {
+      return this.OutputFileFinder.findOutputFiles
+        .calledWith(this.resources, this.basePath)
+        .should.equal(true)
+    })
+
+    it('should delete the output files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'output.pdf'))
+        .should.equal(true)
+    })
+
+    it('should delete the extra files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'extra/file.tex'))
+        .should.equal(true)
+    })
+
+    it('should not delete the extra aux files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'extra.aux'))
+        .should.equal(false)
+    })
+
+    it('should not delete the knitr cache file', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'cache/_chunk1'))
+        .should.equal(false)
+    })
+
+    it('should not delete the epstopdf converted files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(
+          path.join(this.basePath, 'figures/image-eps-converted-to.pdf')
+        )
+        .should.equal(false)
+    })
+
+    it('should not delete the tikz md5 files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'foo/main-figure0.md5'))
+        .should.equal(false)
+    })
+
+    it('should not delete the tikz dpth files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'foo/main-figure0.dpth'))
+        .should.equal(false)
+    })
+
+    it('should not delete the tikz pdf files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, 'foo/main-figure0.pdf'))
+        .should.equal(false)
+    })
+
+    it('should not delete the minted pygstyle files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(
+          path.join(this.basePath, '_minted-main/default-pyg-prefix.pygstyle')
+        )
+        .should.equal(false)
+    })
+
+    it('should not delete the minted default pygstyle files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(path.join(this.basePath, '_minted-main/default.pygstyle'))
+        .should.equal(false)
+    })
+
+    it('should not delete the minted default pygtex files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(
+          path.join(
+            this.basePath,
+            '_minted-main/35E248B60965545BD232AE9F0FE9750D504A7AF0CD3BAA7542030FC560DFCC45.pygtex'
+          )
+        )
+        .should.equal(false)
+    })
+
+    it('should not delete the markdown md.tex files', function() {
+      return this.ResourceWriter._deleteFileIfNotDirectory
+        .calledWith(
+          path.join(
+            this.basePath,
+            '_markdown_main/30893013dec5d869a415610079774c2f.md.tex'
+          )
+        )
+        .should.equal(false)
+    })
+
+    it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+
+    return it('should time the request', function() {
+      return this.Metrics.Timer.prototype.done.called.should.equal(true)
+    })
+  })
+
+  describe('_writeResourceToDisk', function() {
+    describe('with a url based resource', function() {
+      beforeEach(function() {
+        this.resource = {
+          path: 'main.tex',
+          url: 'http://www.example.com/main.tex',
+          modified: Date.now()
+        }
+        this.UrlCache.downloadUrlToFile = sinon
+          .stub()
+          .callsArgWith(4, 'fake error downloading file')
+        return this.ResourceWriter._writeResourceToDisk(
+          this.project_id,
+          this.resource,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should ensure the directory exists', function() {
+        return this.mkdirp
+          .calledWith(
+            path.dirname(path.join(this.basePath, this.resource.path))
+          )
+          .should.equal(true)
+      })
+
+      it('should write the URL from the cache', function() {
+        return this.UrlCache.downloadUrlToFile
+          .calledWith(
+            this.project_id,
+            this.resource.url,
+            path.join(this.basePath, this.resource.path),
+            this.resource.modified
+          )
+          .should.equal(true)
+      })
+
+      it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+
+      return it('should not return an error if the resource writer errored', function() {
+        return should.not.exist(this.callback.args[0][0])
+      })
+    })
+
+    describe('with a content based resource', function() {
+      beforeEach(function() {
+        this.resource = {
+          path: 'main.tex',
+          content: 'Hello world'
+        }
+        this.fs.writeFile = sinon.stub().callsArg(2)
+        return this.ResourceWriter._writeResourceToDisk(
+          this.project_id,
+          this.resource,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should ensure the directory exists', function() {
+        return this.mkdirp
+          .calledWith(
+            path.dirname(path.join(this.basePath, this.resource.path))
+          )
+          .should.equal(true)
+      })
+
+      it('should write the contents to disk', function() {
+        return this.fs.writeFile
+          .calledWith(
+            path.join(this.basePath, this.resource.path),
+            this.resource.content
+          )
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    return describe('with a file path that breaks out of the root folder', function() {
+      beforeEach(function() {
+        this.resource = {
+          path: '../../main.tex',
+          content: 'Hello world'
+        }
+        this.fs.writeFile = sinon.stub().callsArg(2)
+        return this.ResourceWriter._writeResourceToDisk(
+          this.project_id,
+          this.resource,
+          this.basePath,
+          this.callback
+        )
+      })
+
+      it('should not write to disk', function() {
+        return this.fs.writeFile.called.should.equal(false)
+      })
+
+      return it('should return an error', function() {
+        return this.callback
+          .calledWith(new Error('resource path is outside root directory'))
+          .should.equal(true)
+      })
+    })
+  })
+
+  return describe('checkPath', function() {
+    describe('with a valid path', function() {
+      beforeEach(function() {
+        return this.ResourceWriter.checkPath('foo', 'bar', this.callback)
+      })
+
+      return it('should return the joined path', function() {
+        return this.callback.calledWith(null, 'foo/bar').should.equal(true)
+      })
+    })
+
+    describe('with an invalid path', function() {
+      beforeEach(function() {
+        return this.ResourceWriter.checkPath(
+          'foo',
+          'baz/../../bar',
+          this.callback
+        )
+      })
+
+      return it('should return an error', function() {
+        return this.callback
+          .calledWith(new Error('resource path is outside root directory'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('with another invalid path matching on a prefix', function() {
+      beforeEach(function() {
+        return this.ResourceWriter.checkPath(
+          'foo',
+          '../foobar/baz',
+          this.callback
+        )
+      })
+
+      return it('should return an error', function() {
+        return this.callback
+          .calledWith(new Error('resource path is outside root directory'))
+          .should.equal(true)
+      })
+    })
+  })
+})

+ 226 - 214
services/clsi/test/unit/js/StaticServerForbidSymlinksTests.js

@@ -9,217 +9,229 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const should = require('chai').should();
-const SandboxedModule = require('sandboxed-module');
-const assert = require('assert');
-const path = require('path');
-const sinon = require('sinon');
-const modulePath = path.join(__dirname, "../../../app/js/StaticServerForbidSymlinks");
-const { expect } = require("chai");
-
-describe("StaticServerForbidSymlinks", function() {
-
-	beforeEach(function() {
-
-		this.settings = { 
-			path: {
-				compilesDir: "/compiles/here"
-			}
-		};
-
-		this.fs = {};
-		this.ForbidSymlinks = SandboxedModule.require(modulePath, { requires: {
-			"settings-sharelatex":this.settings,
-			"logger-sharelatex": { 
-				log() {},
-				warn() {},
-				error() {}
-			},
-			"fs":this.fs
-		}
-	}
-		);
-
-		this.dummyStatic = (rootDir, options) =>
-			(req, res, next) =>
-				// console.log "dummyStatic serving file", rootDir, "called with", req.url
-				// serve it
-				next()
-			
-		;
-
-		this.StaticServerForbidSymlinks = this.ForbidSymlinks(this.dummyStatic, this.settings.path.compilesDir);
-		this.req = { 
-			params: {
-				project_id:"12345"
-			}
-		};
-
-		this.res = {};
-		return this.req.url = "/12345/output.pdf";
-	});
-
-
-	describe("sending a normal file through", function() {
-		beforeEach(function() {
-			return this.fs.realpath = sinon.stub().callsArgWith(1, null, `${this.settings.path.compilesDir}/${this.req.params.project_id}/output.pdf`);
-		});
-
-		return it("should call next", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(200);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res, done);
-		});
-	});
-
-
-	describe("with a missing file", function() {
-		beforeEach(function() {
-			return this.fs.realpath = sinon.stub().callsArgWith(1, {code: 'ENOENT'}, `${this.settings.path.compilesDir}/${this.req.params.project_id}/unknown.pdf`);
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-
-	describe("with a symlink file", function() {
-		beforeEach(function() {
-			return this.fs.realpath = sinon.stub().callsArgWith(1, null, `/etc/${this.req.params.project_id}/output.pdf`);
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-
-	describe("with a relative file", function() {
-		beforeEach(function() {
-			return this.req.url = "/12345/../67890/output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-
-	describe("with a unnormalized file containing .", function() {
-		beforeEach(function() {
-			return this.req.url = "/12345/foo/./output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-
-	describe("with a file containing an empty path", function() {
-		beforeEach(function() {
-			return this.req.url = "/12345/foo//output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-	describe("with a non-project file", function() {
-		beforeEach(function() {
-			return this.req.url = "/.foo/output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-	describe("with a file outside the compiledir", function() {
-		beforeEach(function() {
-			return this.req.url = "/../bar/output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-
-	describe("with a file with no leading /", function() {
-		beforeEach(function() {
-			return this.req.url = "./../bar/output.pdf";
-		});
-
-		return it("should send a 404", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(404);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-
-	describe("with a github style path", function() {
-		beforeEach(function() {
-			this.req.url = "/henryoswald-latex_example/output/output.log";
-			return this.fs.realpath = sinon.stub().callsArgWith(1, null, `${this.settings.path.compilesDir}/henryoswald-latex_example/output/output.log`);
-		});
-
-		return it("should call next", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(200);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res, done);
-		});
-	});
-
-	return describe("with an error from fs.realpath", function() {
-
-		beforeEach(function() {
-			return this.fs.realpath = sinon.stub().callsArgWith(1, "error");
-		});
-
-		return it("should send a 500", function(done){
-			this.res.sendStatus = function(resCode){
-				resCode.should.equal(500);
-				return done();
-			};
-			return this.StaticServerForbidSymlinks(this.req, this.res);
-		});
-	});
-});
-
+const should = require('chai').should()
+const SandboxedModule = require('sandboxed-module')
+const assert = require('assert')
+const path = require('path')
+const sinon = require('sinon')
+const modulePath = path.join(
+  __dirname,
+  '../../../app/js/StaticServerForbidSymlinks'
+)
+const { expect } = require('chai')
+
+describe('StaticServerForbidSymlinks', function() {
+  beforeEach(function() {
+    this.settings = {
+      path: {
+        compilesDir: '/compiles/here'
+      }
+    }
+
+    this.fs = {}
+    this.ForbidSymlinks = SandboxedModule.require(modulePath, {
+      requires: {
+        'settings-sharelatex': this.settings,
+        'logger-sharelatex': {
+          log() {},
+          warn() {},
+          error() {}
+        },
+        fs: this.fs
+      }
+    })
+
+    this.dummyStatic = (rootDir, options) => (req, res, next) =>
+      // console.log "dummyStatic serving file", rootDir, "called with", req.url
+      // serve it
+      next()
+
+    this.StaticServerForbidSymlinks = this.ForbidSymlinks(
+      this.dummyStatic,
+      this.settings.path.compilesDir
+    )
+    this.req = {
+      params: {
+        project_id: '12345'
+      }
+    }
+
+    this.res = {}
+    return (this.req.url = '/12345/output.pdf')
+  })
+
+  describe('sending a normal file through', function() {
+    beforeEach(function() {
+      return (this.fs.realpath = sinon
+        .stub()
+        .callsArgWith(
+          1,
+          null,
+          `${this.settings.path.compilesDir}/${this.req.params.project_id}/output.pdf`
+        ))
+    })
+
+    return it('should call next', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(200)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res, done)
+    })
+  })
+
+  describe('with a missing file', function() {
+    beforeEach(function() {
+      return (this.fs.realpath = sinon
+        .stub()
+        .callsArgWith(
+          1,
+          { code: 'ENOENT' },
+          `${this.settings.path.compilesDir}/${this.req.params.project_id}/unknown.pdf`
+        ))
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a symlink file', function() {
+    beforeEach(function() {
+      return (this.fs.realpath = sinon
+        .stub()
+        .callsArgWith(1, null, `/etc/${this.req.params.project_id}/output.pdf`))
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a relative file', function() {
+    beforeEach(function() {
+      return (this.req.url = '/12345/../67890/output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a unnormalized file containing .', function() {
+    beforeEach(function() {
+      return (this.req.url = '/12345/foo/./output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a file containing an empty path', function() {
+    beforeEach(function() {
+      return (this.req.url = '/12345/foo//output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a non-project file', function() {
+    beforeEach(function() {
+      return (this.req.url = '/.foo/output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a file outside the compiledir', function() {
+    beforeEach(function() {
+      return (this.req.url = '/../bar/output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a file with no leading /', function() {
+    beforeEach(function() {
+      return (this.req.url = './../bar/output.pdf')
+    })
+
+    return it('should send a 404', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(404)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+
+  describe('with a github style path', function() {
+    beforeEach(function() {
+      this.req.url = '/henryoswald-latex_example/output/output.log'
+      return (this.fs.realpath = sinon
+        .stub()
+        .callsArgWith(
+          1,
+          null,
+          `${this.settings.path.compilesDir}/henryoswald-latex_example/output/output.log`
+        ))
+    })
+
+    return it('should call next', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(200)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res, done)
+    })
+  })
+
+  return describe('with an error from fs.realpath', function() {
+    beforeEach(function() {
+      return (this.fs.realpath = sinon.stub().callsArgWith(1, 'error'))
+    })
+
+    return it('should send a 500', function(done) {
+      this.res.sendStatus = function(resCode) {
+        resCode.should.equal(500)
+        return done()
+      }
+      return this.StaticServerForbidSymlinks(this.req, this.res)
+    })
+  })
+})

+ 169 - 137
services/clsi/test/unit/js/TikzManager.js

@@ -8,148 +8,180 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/TikzManager');
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(
+  __dirname,
+  '../../../app/js/TikzManager'
+)
 
 describe('TikzManager', function() {
-	beforeEach(function() {
-		return this.TikzManager = SandboxedModule.require(modulePath, { requires: {
-			"./ResourceWriter": (this.ResourceWriter = {}),
-			"./SafeReader": (this.SafeReader = {}),
-			"fs": (this.fs = {}),
-			"logger-sharelatex": (this.logger = {log() {}})
-		}
-	});});
-
-	describe("checkMainFile", function() {
-		beforeEach(function() {
-				this.compileDir = "compile-dir";
-				this.mainFile = "main.tex";
-				return this.callback = sinon.stub();
-		});
-
-		describe("if there is already an output.tex file in the resources", function() {
-			beforeEach(function() {
-				this.resources = [{path:"main.tex"},{path:"output.tex"}];
-				return this.TikzManager.checkMainFile(this.compileDir, this.mainFile, this.resources, this.callback);
-			});
-
-			return it("should call the callback with false ", function() {
-				return this.callback.calledWithExactly(null, false)
-				.should.equal(true);
-			});
-		});
-
-		return describe("if there is no output.tex file in the resources", function() {
-			beforeEach(function() {
-				this.resources = [{path:"main.tex"}];
-				return this.ResourceWriter.checkPath = sinon.stub()
-					.withArgs(this.compileDir, this.mainFile)
-					.callsArgWith(2, null, `${this.compileDir}/${this.mainFile}`);
-			});
-
-			describe("and the main file contains tikzexternalize", function() {
-				beforeEach(function() {
-					this.SafeReader.readFile = sinon.stub()
-						.withArgs(`${this.compileDir}/${this.mainFile}`)
-						.callsArgWith(3, null, "hello \\tikzexternalize");
-					return this.TikzManager.checkMainFile(this.compileDir, this.mainFile, this.resources, this.callback);
-				});
-
-				it("should look at the file on disk", function() {
-					return this.SafeReader.readFile
-					.calledWith(`${this.compileDir}/${this.mainFile}`)
-					.should.equal(true);
-				});
-
-				return it("should call the callback with true ", function() {
-					return this.callback.calledWithExactly(null, true)
-					.should.equal(true);
-				});
-			});
-
-			describe("and the main file does not contain tikzexternalize", function() {
-				beforeEach(function() {
-					this.SafeReader.readFile = sinon.stub()
-						.withArgs(`${this.compileDir}/${this.mainFile}`)
-						.callsArgWith(3, null, "hello");
-					return this.TikzManager.checkMainFile(this.compileDir, this.mainFile, this.resources, this.callback);
-				});
-
-				it("should look at the file on disk", function() {
-					return this.SafeReader.readFile
-					.calledWith(`${this.compileDir}/${this.mainFile}`)
-					.should.equal(true);
-				});
-
-				return it("should call the callback with false", function() {
-					return this.callback.calledWithExactly(null, false)
-					.should.equal(true);
-				});
-			});
-
-			return describe("and the main file contains \\usepackage{pstool}", function() {
-				beforeEach(function() {
-					this.SafeReader.readFile = sinon.stub()
-						.withArgs(`${this.compileDir}/${this.mainFile}`)
-						.callsArgWith(3, null, "hello \\usepackage[random-options]{pstool}");
-					return this.TikzManager.checkMainFile(this.compileDir, this.mainFile, this.resources, this.callback);
-				});
-
-				it("should look at the file on disk", function() {
-					return this.SafeReader.readFile
-					.calledWith(`${this.compileDir}/${this.mainFile}`)
-					.should.equal(true);
-				});
-
-				return it("should call the callback with true ", function() {
-					return this.callback.calledWithExactly(null, true)
-					.should.equal(true);
-				});
-			});
-		});
-	});
-
-	return describe("injectOutputFile", function() {
-		beforeEach(function() {
-			this.rootDir = "/mock";
-			this.filename = "filename.tex";
-			this.callback = sinon.stub();
-			this.content = `\
+  beforeEach(function() {
+    return (this.TikzManager = SandboxedModule.require(modulePath, {
+      requires: {
+        './ResourceWriter': (this.ResourceWriter = {}),
+        './SafeReader': (this.SafeReader = {}),
+        fs: (this.fs = {}),
+        'logger-sharelatex': (this.logger = { log() {} })
+      }
+    }))
+  })
+
+  describe('checkMainFile', function() {
+    beforeEach(function() {
+      this.compileDir = 'compile-dir'
+      this.mainFile = 'main.tex'
+      return (this.callback = sinon.stub())
+    })
+
+    describe('if there is already an output.tex file in the resources', function() {
+      beforeEach(function() {
+        this.resources = [{ path: 'main.tex' }, { path: 'output.tex' }]
+        return this.TikzManager.checkMainFile(
+          this.compileDir,
+          this.mainFile,
+          this.resources,
+          this.callback
+        )
+      })
+
+      return it('should call the callback with false ', function() {
+        return this.callback.calledWithExactly(null, false).should.equal(true)
+      })
+    })
+
+    return describe('if there is no output.tex file in the resources', function() {
+      beforeEach(function() {
+        this.resources = [{ path: 'main.tex' }]
+        return (this.ResourceWriter.checkPath = sinon
+          .stub()
+          .withArgs(this.compileDir, this.mainFile)
+          .callsArgWith(2, null, `${this.compileDir}/${this.mainFile}`))
+      })
+
+      describe('and the main file contains tikzexternalize', function() {
+        beforeEach(function() {
+          this.SafeReader.readFile = sinon
+            .stub()
+            .withArgs(`${this.compileDir}/${this.mainFile}`)
+            .callsArgWith(3, null, 'hello \\tikzexternalize')
+          return this.TikzManager.checkMainFile(
+            this.compileDir,
+            this.mainFile,
+            this.resources,
+            this.callback
+          )
+        })
+
+        it('should look at the file on disk', function() {
+          return this.SafeReader.readFile
+            .calledWith(`${this.compileDir}/${this.mainFile}`)
+            .should.equal(true)
+        })
+
+        return it('should call the callback with true ', function() {
+          return this.callback.calledWithExactly(null, true).should.equal(true)
+        })
+      })
+
+      describe('and the main file does not contain tikzexternalize', function() {
+        beforeEach(function() {
+          this.SafeReader.readFile = sinon
+            .stub()
+            .withArgs(`${this.compileDir}/${this.mainFile}`)
+            .callsArgWith(3, null, 'hello')
+          return this.TikzManager.checkMainFile(
+            this.compileDir,
+            this.mainFile,
+            this.resources,
+            this.callback
+          )
+        })
+
+        it('should look at the file on disk', function() {
+          return this.SafeReader.readFile
+            .calledWith(`${this.compileDir}/${this.mainFile}`)
+            .should.equal(true)
+        })
+
+        return it('should call the callback with false', function() {
+          return this.callback.calledWithExactly(null, false).should.equal(true)
+        })
+      })
+
+      return describe('and the main file contains \\usepackage{pstool}', function() {
+        beforeEach(function() {
+          this.SafeReader.readFile = sinon
+            .stub()
+            .withArgs(`${this.compileDir}/${this.mainFile}`)
+            .callsArgWith(3, null, 'hello \\usepackage[random-options]{pstool}')
+          return this.TikzManager.checkMainFile(
+            this.compileDir,
+            this.mainFile,
+            this.resources,
+            this.callback
+          )
+        })
+
+        it('should look at the file on disk', function() {
+          return this.SafeReader.readFile
+            .calledWith(`${this.compileDir}/${this.mainFile}`)
+            .should.equal(true)
+        })
+
+        return it('should call the callback with true ', function() {
+          return this.callback.calledWithExactly(null, true).should.equal(true)
+        })
+      })
+    })
+  })
+
+  return describe('injectOutputFile', function() {
+    beforeEach(function() {
+      this.rootDir = '/mock'
+      this.filename = 'filename.tex'
+      this.callback = sinon.stub()
+      this.content = `\
 \\documentclass{article}
 \\usepackage{tikz}
 \\tikzexternalize
 \\begin{document}
 Hello world
 \\end{document}\
-`;
-			this.fs.readFile = sinon.stub().callsArgWith(2, null, this.content);
-			this.fs.writeFile = sinon.stub().callsArg(3);
-			this.ResourceWriter.checkPath = sinon.stub().callsArgWith(2, null, `${this.rootDir}/${this.filename}`);
-			return this.TikzManager.injectOutputFile(this.rootDir, this.filename, this.callback);
-		});
-
-		it("sould check the path", function() {
-			return this.ResourceWriter.checkPath.calledWith(this.rootDir, this.filename)
-			.should.equal(true);
-		});
-
-		it("should read the file", function() {
-			return this.fs.readFile
-				.calledWith(`${this.rootDir}/${this.filename}`, "utf8")
-				.should.equal(true);
-		});
-
-		it("should write out the same file as output.tex", function() {
-			return this.fs.writeFile
-				.calledWith(`${this.rootDir}/output.tex`, this.content, {flag: 'wx'})
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
+`
+      this.fs.readFile = sinon.stub().callsArgWith(2, null, this.content)
+      this.fs.writeFile = sinon.stub().callsArg(3)
+      this.ResourceWriter.checkPath = sinon
+        .stub()
+        .callsArgWith(2, null, `${this.rootDir}/${this.filename}`)
+      return this.TikzManager.injectOutputFile(
+        this.rootDir,
+        this.filename,
+        this.callback
+      )
+    })
+
+    it('sould check the path', function() {
+      return this.ResourceWriter.checkPath
+        .calledWith(this.rootDir, this.filename)
+        .should.equal(true)
+    })
+
+    it('should read the file', function() {
+      return this.fs.readFile
+        .calledWith(`${this.rootDir}/${this.filename}`, 'utf8')
+        .should.equal(true)
+    })
+
+    it('should write out the same file as output.tex', function() {
+      return this.fs.writeFile
+        .calledWith(`${this.rootDir}/output.tex`, this.content, { flag: 'wx' })
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 344 - 256
services/clsi/test/unit/js/UrlCacheTests.js

@@ -10,259 +10,347 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/UrlCache');
-const { EventEmitter } = require("events");
-
-describe("UrlCache", function() {
-	beforeEach(function() {
-		this.callback = sinon.stub();
-		this.url = "www.example.com/file";
-		this.project_id = "project-id-123";
-		return this.UrlCache = SandboxedModule.require(modulePath, { requires: {
-			"./db" : {},
-			"./UrlFetcher" : (this.UrlFetcher = {}),
-			"logger-sharelatex": (this.logger = {log: sinon.stub()}),
-			"settings-sharelatex": (this.Settings = { path: {clsiCacheDir: "/cache/dir"} }),
-			"fs": (this.fs = {})
-		}
-	});});
-	
-	describe("_doesUrlNeedDownloading", function() {
-		beforeEach(function() {
-			this.lastModified = new Date();
-			return this.lastModifiedRoundedToSeconds = new Date(Math.floor(this.lastModified.getTime() / 1000) * 1000);
-		});
-
-		describe("when URL does not exist in cache", function() {
-			beforeEach(function() {
-				this.UrlCache._findUrlDetails = sinon.stub().callsArgWith(2, null, null);
-				return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-			});
-
-			return it("should return the callback with true", function() {
-				return this.callback.calledWith(null, true).should.equal(true);
-			});
-		});
-
-		return describe("when URL does exist in cache", function() {
-			beforeEach(function() {
-				this.urlDetails = {};
-				return this.UrlCache._findUrlDetails = sinon.stub().callsArgWith(2, null, this.urlDetails);
-			});
-
-			describe("when the modified date is more recent than the cached modified date", function() {
-				beforeEach(function() {
-					this.urlDetails.lastModified = new Date(this.lastModified.getTime() - 1000);
-					return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-				});
-
-				it("should get the url details", function() {
-					return this.UrlCache._findUrlDetails
-						.calledWith(this.project_id, this.url)
-						.should.equal(true);
-				});
-
-				return it("should return the callback with true", function() {
-					return this.callback.calledWith(null, true).should.equal(true);
-				});
-			});
-
-			describe("when the cached modified date is more recent than the modified date", function() {
-				beforeEach(function() {
-					this.urlDetails.lastModified = new Date(this.lastModified.getTime() + 1000);
-					return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-				});
-
-				return it("should return the callback with false", function() {
-					return this.callback.calledWith(null, false).should.equal(true);
-				});
-			});
-
-			describe("when the cached modified date is equal to the modified date", function() {
-				beforeEach(function() {
-					this.urlDetails.lastModified = this.lastModified;
-					return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-				});
-
-				return it("should return the callback with false", function() {
-					return this.callback.calledWith(null, false).should.equal(true);
-				});
-			});
-
-			describe("when the provided modified date does not exist", function() {
-				beforeEach(function() {
-					this.lastModified = null;
-					return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-				});
-
-				return it("should return the callback with true", function() {
-					return this.callback.calledWith(null, true).should.equal(true);
-				});
-			});
-
-			return describe("when the URL does not have a modified date", function() {
-				beforeEach(function() {
-					this.urlDetails.lastModified = null;
-					return this.UrlCache._doesUrlNeedDownloading(this.project_id, this.url, this.lastModified, this.callback);
-				});
-
-				return it("should return the callback with true", function() {
-					return this.callback.calledWith(null, true).should.equal(true);
-				});
-			});
-		});
-	});
-
-	describe("_ensureUrlIsInCache", function() {
-		beforeEach(function() {
-			this.UrlFetcher.pipeUrlToFile = sinon.stub().callsArg(2);
-			return this.UrlCache._updateOrCreateUrlDetails = sinon.stub().callsArg(3);
-		});
-			
-		describe("when the URL needs updating", function() {
-			beforeEach(function() {
-				this.UrlCache._doesUrlNeedDownloading = sinon.stub().callsArgWith(3, null, true);
-				return this.UrlCache._ensureUrlIsInCache(this.project_id, this.url, this.lastModified, this.callback);
-			});
-
-			it("should check that the url needs downloading", function() {
-				return this.UrlCache._doesUrlNeedDownloading
-					.calledWith(this.project_id, this.url, this.lastModifiedRoundedToSeconds)
-					.should.equal(true);
-			});
-
-			it("should download the URL to the cache file", function() {
-				return this.UrlFetcher.pipeUrlToFile
-					.calledWith(this.url, this.UrlCache._cacheFilePathForUrl(this.project_id, this.url))
-					.should.equal(true);
-			});
-				
-
-			it("should update the database entry", function() {
-				return this.UrlCache._updateOrCreateUrlDetails
-					.calledWith(this.project_id, this.url, this.lastModifiedRoundedToSeconds)
-					.should.equal(true);
-			});
-
-			return it("should return the callback with the cache file path", function() {
-				return this.callback
-					.calledWith(null, this.UrlCache._cacheFilePathForUrl(this.project_id, this.url))
-					.should.equal(true);
-			});
-		});
-
-		return describe("when the URL does not need updating", function() {
-			beforeEach(function() {
-				this.UrlCache._doesUrlNeedDownloading = sinon.stub().callsArgWith(3, null, false);
-				return this.UrlCache._ensureUrlIsInCache(this.project_id, this.url, this.lastModified, this.callback);
-			});
-				
-			it("should not download the URL to the cache file", function() {
-				return this.UrlFetcher.pipeUrlToFile
-					.called.should.equal(false);
-			});
-
-			return it("should return the callback with the cache file path", function() {
-				return this.callback
-					.calledWith(null, this.UrlCache._cacheFilePathForUrl(this.project_id, this.url))
-					.should.equal(true);
-			});
-		});
-	});
-
-	describe("downloadUrlToFile", function() {
-		beforeEach(function() {
-			this.cachePath = "path/to/cached/url";
-			this.destPath = "path/to/destination";
-			this.UrlCache._copyFile = sinon.stub().callsArg(2);
-			this.UrlCache._ensureUrlIsInCache = sinon.stub().callsArgWith(3, null, this.cachePath);
-			return this.UrlCache.downloadUrlToFile(this.project_id, this.url, this.destPath, this.lastModified, this.callback);
-		});
-
-		it("should ensure the URL is downloaded and updated in the cache", function() {
-			return this.UrlCache._ensureUrlIsInCache
-				.calledWith(this.project_id, this.url, this.lastModified)
-				.should.equal(true);
-		});
-
-		it("should copy the file to the new location", function() {
-			return this.UrlCache._copyFile
-				.calledWith(this.cachePath, this.destPath)
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("_deleteUrlCacheFromDisk", function() {
-		beforeEach(function() {
-			this.fs.unlink = sinon.stub().callsArg(1);
-			return this.UrlCache._deleteUrlCacheFromDisk(this.project_id, this.url, this.callback);
-		});
-
-		it("should delete the cache file", function() {
-			return this.fs.unlink
-				.calledWith(this.UrlCache._cacheFilePathForUrl(this.project_id, this.url))
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	describe("_clearUrlFromCache", function() {
-		beforeEach(function() {
-			this.UrlCache._deleteUrlCacheFromDisk = sinon.stub().callsArg(2);
-			this.UrlCache._clearUrlDetails = sinon.stub().callsArg(2);
-			return this.UrlCache._clearUrlFromCache(this.project_id, this.url, this.callback);
-		});
-
-		it("should delete the file on disk", function() {
-			return this.UrlCache._deleteUrlCacheFromDisk
-				.calledWith(this.project_id, this.url)
-				.should.equal(true);
-		});
-
-		it("should clear the entry in the database", function() {
-			return this.UrlCache._clearUrlDetails
-				.calledWith(this.project_id, this.url)
-				.should.equal(true);
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-
-	return describe("clearProject", function() {
-		beforeEach(function() {
-			this.urls = [
-				"www.example.com/file1",
-				"www.example.com/file2"
-			];
-			this.UrlCache._findAllUrlsInProject = sinon.stub().callsArgWith(1, null, this.urls);
-			this.UrlCache._clearUrlFromCache = sinon.stub().callsArg(2);
-			return this.UrlCache.clearProject(this.project_id, this.callback);
-		});
-
-		it("should clear the cache for each url in the project", function() {
-			return Array.from(this.urls).map((url) =>
-				this.UrlCache._clearUrlFromCache
-					.calledWith(this.project_id, url)
-					.should.equal(true));
-		});
-
-		return it("should call the callback", function() {
-			return this.callback.called.should.equal(true);
-		});
-	});
-});
-			
-				
-
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(__dirname, '../../../app/js/UrlCache')
+const { EventEmitter } = require('events')
+
+describe('UrlCache', function() {
+  beforeEach(function() {
+    this.callback = sinon.stub()
+    this.url = 'www.example.com/file'
+    this.project_id = 'project-id-123'
+    return (this.UrlCache = SandboxedModule.require(modulePath, {
+      requires: {
+        './db': {},
+        './UrlFetcher': (this.UrlFetcher = {}),
+        'logger-sharelatex': (this.logger = { log: sinon.stub() }),
+        'settings-sharelatex': (this.Settings = {
+          path: { clsiCacheDir: '/cache/dir' }
+        }),
+        fs: (this.fs = {})
+      }
+    }))
+  })
+
+  describe('_doesUrlNeedDownloading', function() {
+    beforeEach(function() {
+      this.lastModified = new Date()
+      return (this.lastModifiedRoundedToSeconds = new Date(
+        Math.floor(this.lastModified.getTime() / 1000) * 1000
+      ))
+    })
+
+    describe('when URL does not exist in cache', function() {
+      beforeEach(function() {
+        this.UrlCache._findUrlDetails = sinon.stub().callsArgWith(2, null, null)
+        return this.UrlCache._doesUrlNeedDownloading(
+          this.project_id,
+          this.url,
+          this.lastModified,
+          this.callback
+        )
+      })
+
+      return it('should return the callback with true', function() {
+        return this.callback.calledWith(null, true).should.equal(true)
+      })
+    })
+
+    return describe('when URL does exist in cache', function() {
+      beforeEach(function() {
+        this.urlDetails = {}
+        return (this.UrlCache._findUrlDetails = sinon
+          .stub()
+          .callsArgWith(2, null, this.urlDetails))
+      })
+
+      describe('when the modified date is more recent than the cached modified date', function() {
+        beforeEach(function() {
+          this.urlDetails.lastModified = new Date(
+            this.lastModified.getTime() - 1000
+          )
+          return this.UrlCache._doesUrlNeedDownloading(
+            this.project_id,
+            this.url,
+            this.lastModified,
+            this.callback
+          )
+        })
+
+        it('should get the url details', function() {
+          return this.UrlCache._findUrlDetails
+            .calledWith(this.project_id, this.url)
+            .should.equal(true)
+        })
+
+        return it('should return the callback with true', function() {
+          return this.callback.calledWith(null, true).should.equal(true)
+        })
+      })
+
+      describe('when the cached modified date is more recent than the modified date', function() {
+        beforeEach(function() {
+          this.urlDetails.lastModified = new Date(
+            this.lastModified.getTime() + 1000
+          )
+          return this.UrlCache._doesUrlNeedDownloading(
+            this.project_id,
+            this.url,
+            this.lastModified,
+            this.callback
+          )
+        })
+
+        return it('should return the callback with false', function() {
+          return this.callback.calledWith(null, false).should.equal(true)
+        })
+      })
+
+      describe('when the cached modified date is equal to the modified date', function() {
+        beforeEach(function() {
+          this.urlDetails.lastModified = this.lastModified
+          return this.UrlCache._doesUrlNeedDownloading(
+            this.project_id,
+            this.url,
+            this.lastModified,
+            this.callback
+          )
+        })
+
+        return it('should return the callback with false', function() {
+          return this.callback.calledWith(null, false).should.equal(true)
+        })
+      })
+
+      describe('when the provided modified date does not exist', function() {
+        beforeEach(function() {
+          this.lastModified = null
+          return this.UrlCache._doesUrlNeedDownloading(
+            this.project_id,
+            this.url,
+            this.lastModified,
+            this.callback
+          )
+        })
+
+        return it('should return the callback with true', function() {
+          return this.callback.calledWith(null, true).should.equal(true)
+        })
+      })
+
+      return describe('when the URL does not have a modified date', function() {
+        beforeEach(function() {
+          this.urlDetails.lastModified = null
+          return this.UrlCache._doesUrlNeedDownloading(
+            this.project_id,
+            this.url,
+            this.lastModified,
+            this.callback
+          )
+        })
+
+        return it('should return the callback with true', function() {
+          return this.callback.calledWith(null, true).should.equal(true)
+        })
+      })
+    })
+  })
+
+  describe('_ensureUrlIsInCache', function() {
+    beforeEach(function() {
+      this.UrlFetcher.pipeUrlToFile = sinon.stub().callsArg(2)
+      return (this.UrlCache._updateOrCreateUrlDetails = sinon
+        .stub()
+        .callsArg(3))
+    })
+
+    describe('when the URL needs updating', function() {
+      beforeEach(function() {
+        this.UrlCache._doesUrlNeedDownloading = sinon
+          .stub()
+          .callsArgWith(3, null, true)
+        return this.UrlCache._ensureUrlIsInCache(
+          this.project_id,
+          this.url,
+          this.lastModified,
+          this.callback
+        )
+      })
+
+      it('should check that the url needs downloading', function() {
+        return this.UrlCache._doesUrlNeedDownloading
+          .calledWith(
+            this.project_id,
+            this.url,
+            this.lastModifiedRoundedToSeconds
+          )
+          .should.equal(true)
+      })
+
+      it('should download the URL to the cache file', function() {
+        return this.UrlFetcher.pipeUrlToFile
+          .calledWith(
+            this.url,
+            this.UrlCache._cacheFilePathForUrl(this.project_id, this.url)
+          )
+          .should.equal(true)
+      })
+
+      it('should update the database entry', function() {
+        return this.UrlCache._updateOrCreateUrlDetails
+          .calledWith(
+            this.project_id,
+            this.url,
+            this.lastModifiedRoundedToSeconds
+          )
+          .should.equal(true)
+      })
+
+      return it('should return the callback with the cache file path', function() {
+        return this.callback
+          .calledWith(
+            null,
+            this.UrlCache._cacheFilePathForUrl(this.project_id, this.url)
+          )
+          .should.equal(true)
+      })
+    })
+
+    return describe('when the URL does not need updating', function() {
+      beforeEach(function() {
+        this.UrlCache._doesUrlNeedDownloading = sinon
+          .stub()
+          .callsArgWith(3, null, false)
+        return this.UrlCache._ensureUrlIsInCache(
+          this.project_id,
+          this.url,
+          this.lastModified,
+          this.callback
+        )
+      })
+
+      it('should not download the URL to the cache file', function() {
+        return this.UrlFetcher.pipeUrlToFile.called.should.equal(false)
+      })
+
+      return it('should return the callback with the cache file path', function() {
+        return this.callback
+          .calledWith(
+            null,
+            this.UrlCache._cacheFilePathForUrl(this.project_id, this.url)
+          )
+          .should.equal(true)
+      })
+    })
+  })
+
+  describe('downloadUrlToFile', function() {
+    beforeEach(function() {
+      this.cachePath = 'path/to/cached/url'
+      this.destPath = 'path/to/destination'
+      this.UrlCache._copyFile = sinon.stub().callsArg(2)
+      this.UrlCache._ensureUrlIsInCache = sinon
+        .stub()
+        .callsArgWith(3, null, this.cachePath)
+      return this.UrlCache.downloadUrlToFile(
+        this.project_id,
+        this.url,
+        this.destPath,
+        this.lastModified,
+        this.callback
+      )
+    })
+
+    it('should ensure the URL is downloaded and updated in the cache', function() {
+      return this.UrlCache._ensureUrlIsInCache
+        .calledWith(this.project_id, this.url, this.lastModified)
+        .should.equal(true)
+    })
+
+    it('should copy the file to the new location', function() {
+      return this.UrlCache._copyFile
+        .calledWith(this.cachePath, this.destPath)
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('_deleteUrlCacheFromDisk', function() {
+    beforeEach(function() {
+      this.fs.unlink = sinon.stub().callsArg(1)
+      return this.UrlCache._deleteUrlCacheFromDisk(
+        this.project_id,
+        this.url,
+        this.callback
+      )
+    })
+
+    it('should delete the cache file', function() {
+      return this.fs.unlink
+        .calledWith(
+          this.UrlCache._cacheFilePathForUrl(this.project_id, this.url)
+        )
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  describe('_clearUrlFromCache', function() {
+    beforeEach(function() {
+      this.UrlCache._deleteUrlCacheFromDisk = sinon.stub().callsArg(2)
+      this.UrlCache._clearUrlDetails = sinon.stub().callsArg(2)
+      return this.UrlCache._clearUrlFromCache(
+        this.project_id,
+        this.url,
+        this.callback
+      )
+    })
+
+    it('should delete the file on disk', function() {
+      return this.UrlCache._deleteUrlCacheFromDisk
+        .calledWith(this.project_id, this.url)
+        .should.equal(true)
+    })
+
+    it('should clear the entry in the database', function() {
+      return this.UrlCache._clearUrlDetails
+        .calledWith(this.project_id, this.url)
+        .should.equal(true)
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+
+  return describe('clearProject', function() {
+    beforeEach(function() {
+      this.urls = ['www.example.com/file1', 'www.example.com/file2']
+      this.UrlCache._findAllUrlsInProject = sinon
+        .stub()
+        .callsArgWith(1, null, this.urls)
+      this.UrlCache._clearUrlFromCache = sinon.stub().callsArg(2)
+      return this.UrlCache.clearProject(this.project_id, this.callback)
+    })
+
+    it('should clear the cache for each url in the project', function() {
+      return Array.from(this.urls).map(url =>
+        this.UrlCache._clearUrlFromCache
+          .calledWith(this.project_id, url)
+          .should.equal(true)
+      )
+    })
+
+    return it('should call the callback', function() {
+      return this.callback.called.should.equal(true)
+    })
+  })
+})

+ 162 - 149
services/clsi/test/unit/js/UrlFetcherTests.js

@@ -8,152 +8,165 @@
  * DS102: Remove unnecessary code created because of implicit returns
  * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
  */
-const SandboxedModule = require('sandboxed-module');
-const sinon = require('sinon');
-require('chai').should();
-const modulePath = require('path').join(__dirname, '../../../app/js/UrlFetcher');
-const { EventEmitter } = require("events");
-
-describe("UrlFetcher", function() {
-	beforeEach(function() {
-		this.callback = sinon.stub();
-		this.url = "https://www.example.com/file/here?query=string";
-		return this.UrlFetcher = SandboxedModule.require(modulePath, { requires: {
-			request: { defaults: (this.defaults = sinon.stub().returns(this.request = {}))
-		},
-			fs: (this.fs = {}),
-			"logger-sharelatex": (this.logger = { log: sinon.stub(), error: sinon.stub() }),
-			"settings-sharelatex": (this.settings = {})
-		}
-	});});
-
-	it("should turn off the cookie jar in request", function() {
-		return this.defaults.calledWith({jar: false})
-			.should.equal(true);
-	});
-
-	describe("rewrite url domain if filestoreDomainOveride is set", function() {
-		beforeEach(function() {
-			this.path = "/path/to/file/on/disk";
-			this.request.get = sinon.stub().returns(this.urlStream = new EventEmitter);
-			this.urlStream.pipe = sinon.stub();
-			this.urlStream.pause = sinon.stub();
-			this.urlStream.resume = sinon.stub();
-			this.fs.createWriteStream = sinon.stub().returns(this.fileStream = new EventEmitter);
-			return this.fs.unlink = (file, callback) => callback();
-		});
-
-		it("should use the normal domain when override not set", function(done){
-			this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
-				this.request.get.args[0][0].url.should.equal(this.url);
-				return done();
-			});
-			this.res = {statusCode: 200};
-			this.urlStream.emit("response", this.res);
-			this.urlStream.emit("end");
-			return this.fileStream.emit("finish");
-		});
-
-
-		return it("should use override domain when filestoreDomainOveride is set", function(done){
-			this.settings.filestoreDomainOveride = "192.11.11.11";
-			this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
-				this.request.get.args[0][0].url.should.equal("192.11.11.11/file/here?query=string");
-				return done();
-			});
-			this.res = {statusCode: 200};
-			this.urlStream.emit("response", this.res);
-			this.urlStream.emit("end");
-			return this.fileStream.emit("finish");
-		});
-	});
-
-	return describe("pipeUrlToFile", function() {
-		beforeEach(function(done){
-			this.path = "/path/to/file/on/disk";
-			this.request.get = sinon.stub().returns(this.urlStream = new EventEmitter);
-			this.urlStream.pipe = sinon.stub();
-			this.urlStream.pause = sinon.stub();
-			this.urlStream.resume = sinon.stub();
-			this.fs.createWriteStream = sinon.stub().returns(this.fileStream = new EventEmitter);
-			this.fs.unlink = (file, callback) => callback();
-			return done();
-		});
-
-		describe("successfully", function() {
-			beforeEach(function(done){
-				this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
-					this.callback();
-					return done();
-				});
-				this.res = {statusCode: 200};
-				this.urlStream.emit("response", this.res);
-				this.urlStream.emit("end");
-				return this.fileStream.emit("finish");
-			});
-
-
-			it("should request the URL", function() {
-				return this.request.get
-					.calledWith(sinon.match({"url": this.url}))
-					.should.equal(true);
-			});
-
-			it("should open the file for writing", function() {
-				return this.fs.createWriteStream
-					.calledWith(this.path)
-					.should.equal(true);
-			});
-
-			it("should pipe the URL to the file", function() {
-				return this.urlStream.pipe
-					.calledWith(this.fileStream)
-					.should.equal(true);
-			});
-		
-			return it("should call the callback", function() {
-				return this.callback.called.should.equal(true);
-			});
-		});
-
-		describe("with non success status code", function() {
-			beforeEach(function(done){
-				this.UrlFetcher.pipeUrlToFile(this.url, this.path, err=> {
-					this.callback(err);
-					return done();
-				});	
-				this.res = {statusCode: 404};
-				this.urlStream.emit("response", this.res);
-				return this.urlStream.emit("end");
-			});
-
-			return it("should call the callback with an error", function() {
-				return this.callback
-					.calledWith(new Error("URL returned non-success status code: 404"))
-					.should.equal(true);
-			});
-		});
-
-		return describe("with error", function() {
-			beforeEach(function(done){
-				this.UrlFetcher.pipeUrlToFile(this.url, this.path, err=> {
-					this.callback(err);
-					return done();
-				});
-				return this.urlStream.emit("error", (this.error = new Error("something went wrong")));
-			});
-
-			it("should call the callback with the error", function() {
-				return this.callback
-					.calledWith(this.error)
-					.should.equal(true);
-			});
-
-			return it("should only call the callback once, even if end is called", function() {
-				this.urlStream.emit("end");
-				return this.callback.calledOnce.should.equal(true);
-			});
-		});
-	});
-});
-
+const SandboxedModule = require('sandboxed-module')
+const sinon = require('sinon')
+require('chai').should()
+const modulePath = require('path').join(__dirname, '../../../app/js/UrlFetcher')
+const { EventEmitter } = require('events')
+
+describe('UrlFetcher', function() {
+  beforeEach(function() {
+    this.callback = sinon.stub()
+    this.url = 'https://www.example.com/file/here?query=string'
+    return (this.UrlFetcher = SandboxedModule.require(modulePath, {
+      requires: {
+        request: {
+          defaults: (this.defaults = sinon.stub().returns((this.request = {})))
+        },
+        fs: (this.fs = {}),
+        'logger-sharelatex': (this.logger = {
+          log: sinon.stub(),
+          error: sinon.stub()
+        }),
+        'settings-sharelatex': (this.settings = {})
+      }
+    }))
+  })
+
+  it('should turn off the cookie jar in request', function() {
+    return this.defaults.calledWith({ jar: false }).should.equal(true)
+  })
+
+  describe('rewrite url domain if filestoreDomainOveride is set', function() {
+    beforeEach(function() {
+      this.path = '/path/to/file/on/disk'
+      this.request.get = sinon
+        .stub()
+        .returns((this.urlStream = new EventEmitter()))
+      this.urlStream.pipe = sinon.stub()
+      this.urlStream.pause = sinon.stub()
+      this.urlStream.resume = sinon.stub()
+      this.fs.createWriteStream = sinon
+        .stub()
+        .returns((this.fileStream = new EventEmitter()))
+      return (this.fs.unlink = (file, callback) => callback())
+    })
+
+    it('should use the normal domain when override not set', function(done) {
+      this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
+        this.request.get.args[0][0].url.should.equal(this.url)
+        return done()
+      })
+      this.res = { statusCode: 200 }
+      this.urlStream.emit('response', this.res)
+      this.urlStream.emit('end')
+      return this.fileStream.emit('finish')
+    })
+
+    return it('should use override domain when filestoreDomainOveride is set', function(done) {
+      this.settings.filestoreDomainOveride = '192.11.11.11'
+      this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
+        this.request.get.args[0][0].url.should.equal(
+          '192.11.11.11/file/here?query=string'
+        )
+        return done()
+      })
+      this.res = { statusCode: 200 }
+      this.urlStream.emit('response', this.res)
+      this.urlStream.emit('end')
+      return this.fileStream.emit('finish')
+    })
+  })
+
+  return describe('pipeUrlToFile', function() {
+    beforeEach(function(done) {
+      this.path = '/path/to/file/on/disk'
+      this.request.get = sinon
+        .stub()
+        .returns((this.urlStream = new EventEmitter()))
+      this.urlStream.pipe = sinon.stub()
+      this.urlStream.pause = sinon.stub()
+      this.urlStream.resume = sinon.stub()
+      this.fs.createWriteStream = sinon
+        .stub()
+        .returns((this.fileStream = new EventEmitter()))
+      this.fs.unlink = (file, callback) => callback()
+      return done()
+    })
+
+    describe('successfully', function() {
+      beforeEach(function(done) {
+        this.UrlFetcher.pipeUrlToFile(this.url, this.path, () => {
+          this.callback()
+          return done()
+        })
+        this.res = { statusCode: 200 }
+        this.urlStream.emit('response', this.res)
+        this.urlStream.emit('end')
+        return this.fileStream.emit('finish')
+      })
+
+      it('should request the URL', function() {
+        return this.request.get
+          .calledWith(sinon.match({ url: this.url }))
+          .should.equal(true)
+      })
+
+      it('should open the file for writing', function() {
+        return this.fs.createWriteStream
+          .calledWith(this.path)
+          .should.equal(true)
+      })
+
+      it('should pipe the URL to the file', function() {
+        return this.urlStream.pipe
+          .calledWith(this.fileStream)
+          .should.equal(true)
+      })
+
+      return it('should call the callback', function() {
+        return this.callback.called.should.equal(true)
+      })
+    })
+
+    describe('with non success status code', function() {
+      beforeEach(function(done) {
+        this.UrlFetcher.pipeUrlToFile(this.url, this.path, err => {
+          this.callback(err)
+          return done()
+        })
+        this.res = { statusCode: 404 }
+        this.urlStream.emit('response', this.res)
+        return this.urlStream.emit('end')
+      })
+
+      return it('should call the callback with an error', function() {
+        return this.callback
+          .calledWith(new Error('URL returned non-success status code: 404'))
+          .should.equal(true)
+      })
+    })
+
+    return describe('with error', function() {
+      beforeEach(function(done) {
+        this.UrlFetcher.pipeUrlToFile(this.url, this.path, err => {
+          this.callback(err)
+          return done()
+        })
+        return this.urlStream.emit(
+          'error',
+          (this.error = new Error('something went wrong'))
+        )
+      })
+
+      it('should call the callback with the error', function() {
+        return this.callback.calledWith(this.error).should.equal(true)
+      })
+
+      return it('should only call the callback once, even if end is called', function() {
+        this.urlStream.emit('end')
+        return this.callback.calledOnce.should.equal(true)
+      })
+    })
+  })
+})