diff --git a/.dockerignore b/.dockerignore index eb85ddcc..a67706a4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ assets docs public test +coverage \ No newline at end of file diff --git a/.gitignore b/.gitignore index 82ee25a1..1e6cbed3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ node_modules +coverage dist .idea +.DS_Store +.nyc_output \ No newline at end of file diff --git a/circle.yml b/circle.yml index af534362..a386c272 100644 --- a/circle.yml +++ b/circle.yml @@ -4,13 +4,6 @@ machine: services: - docker - redis - environment: - PATH: "/home/ubuntu/send/firefox:$PATH" - -dependencies: - pre: - - npm i -g get-firefox geckodriver nsp - - get-firefox --platform linux --extract --target /home/ubuntu/send deployment: latest: @@ -31,5 +24,5 @@ test: override: - npm run build - npm run lint - - npm test + - npm run test:ci - nsp check diff --git a/package-lock.json b/package-lock.json index 6756d05f..fccae2b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,15 @@ } } }, + "agent-base": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.2.0.tgz", + "integrity": "sha512-c+R/U5X+2zz2+UCrCFv6odQzJdoqI+YecuhnAJLa1zYaMc13zPfwMwZrr91Pd1DYNo/yPRbiM4WVf9whgwFsIg==", + "dev": true, + "requires": { + "es6-promisify": "5.0.0" + } + }, "ajv": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.0.tgz", @@ -1911,6 +1920,45 @@ "integrity": "sha1-u3ZNiOGF+54eaiofGXcjGPYF4xw=", "dev": true }, + "cli-table2": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/cli-table2/-/cli-table2-0.2.0.tgz", + "integrity": "sha1-LR738hig54biFFQFYtS9F3/jLZc=", + "dev": true, + "requires": { + "colors": "1.1.2", + "lodash": "3.10.1", + "string-width": "1.0.2" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "lodash": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + } + } + }, "cli-truncate": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-0.2.1.tgz", @@ -2781,6 +2829,12 @@ "array-find-index": "1.0.2" } }, + "cvss": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cvss/-/cvss-1.0.2.tgz", + "integrity": "sha1-32fpK/EqeW9J6Sh5nI2zunS5/NY=", + "dev": true + }, "cyclist": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", @@ -3340,6 +3394,23 @@ "integrity": "sha1-AQ1YWEI6XxGJeWZfRkhqlcbuK7Y=", "dev": true }, + "es6-promisify": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", + "integrity": "sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=", + "dev": true, + "requires": { + "es6-promise": "4.2.4" + }, + "dependencies": { + "es6-promise": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.4.tgz", + "integrity": "sha512-/NdNZVJg+uZgtm9eS3O6lrOLYmQag2DjdEXuPaHlZ6RuVqgqaVZfgYCepEIKsLqwdQArOPtC3XzRLqGGfT8KQQ==", + "dev": true + } + } + }, "es6-set": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", @@ -5748,6 +5819,27 @@ "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", "dev": true }, + "https-proxy-agent": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-2.1.1.tgz", + "integrity": "sha512-LK6tQUR/VOkTI6ygAfWUKKP95I+e6M1h7N3PncGu1CATHCnex+CAv9ttR0lbHu1Uk2PXm/WoAHFo6JCGwMjVMw==", + "dev": true, + "requires": { + "agent-base": "4.2.0", + "debug": "3.1.0" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, "husky": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/husky/-/husky-0.14.3.tgz", @@ -7924,6 +8016,12 @@ } } }, + "nodesecurity-npm-utils": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nodesecurity-npm-utils/-/nodesecurity-npm-utils-6.0.0.tgz", + "integrity": "sha512-NLRle1woNaT2orR6fue2jNqkhxDTktgJj3sZxvR/8kp21pvOY7Gwlx5wvo0H8ZVPqdgd2nE2ADB9wDu5Cl8zNg==", + "dev": true + }, "nomnom": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/nomnom/-/nomnom-1.6.2.tgz", @@ -8067,6 +8165,75 @@ "which": "1.3.0" } }, + "nsp": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nsp/-/nsp-3.1.0.tgz", + "integrity": "sha512-jtD2WMlmqWA3zSZTFog8MQkwUvXWdJD7ps7A30q8ImOIao0RFgZXbPPZ8O6yWaKxLbRxVUIoCfwvXyg7AVS+IA==", + "dev": true, + "requires": { + "chalk": "2.3.0", + "cli-table2": "0.2.0", + "cvss": "1.0.2", + "https-proxy-agent": "2.1.1", + "inquirer": "3.3.0", + "nodesecurity-npm-utils": "6.0.0", + "semver": "5.4.1", + "wreck": "12.5.1", + "yargs": "9.0.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.0.tgz", + "integrity": "sha512-NnSOmMEYtVR2JVMIGTzynRkkaxtiq1xnFBcdQD/DnNCYPoEPsVJhM98BDyaoNOQIi7p4okdi3E27eN7GQbsUug==", + "dev": true, + "requires": { + "color-convert": "1.9.1" + } + }, + "chalk": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz", + "integrity": "sha512-Az5zJR2CBujap2rqXGaJKaPHyJ0IrUimvYNX+ncCy8PJP4ltOGTrHUIo097ZaL2zMeKYpiCdqDvS6zdrTFok3Q==", + "dev": true, + "requires": { + "ansi-styles": "3.2.0", + "escape-string-regexp": "1.0.5", + "supports-color": "4.5.0" + } + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "2.0.0" + } + }, + "yargs": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-9.0.1.tgz", + "integrity": "sha1-UqzCP+7Kw0BCB47njAwAf1CF20w=", + "dev": true, + "requires": { + "camelcase": "4.1.0", + "cliui": "3.2.0", + "decamelize": "1.2.0", + "get-caller-file": "1.0.2", + "os-locale": "2.1.0", + "read-pkg-up": "2.0.0", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "2.1.1", + "which-module": "2.0.0", + "y18n": "3.2.1", + "yargs-parser": "7.0.0" + } + } + } + }, "num2fraction": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", @@ -8079,6 +8246,1600 @@ "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true }, + "nyc": { + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-11.4.1.tgz", + "integrity": "sha512-5eCZpvaksFVjP2rt1r60cfXmt3MUtsQDw8bAzNqNEr4WLvUMLgiVENMf/B9bE9YAX0mGVvaGA3v9IS9ekNqB1Q==", + "dev": true, + "requires": { + "archy": "1.0.0", + "arrify": "1.0.1", + "caching-transform": "1.0.1", + "convert-source-map": "1.5.1", + "debug-log": "1.0.1", + "default-require-extensions": "1.0.0", + "find-cache-dir": "0.1.1", + "find-up": "2.1.0", + "foreground-child": "1.5.6", + "glob": "7.1.2", + "istanbul-lib-coverage": "1.1.1", + "istanbul-lib-hook": "1.1.0", + "istanbul-lib-instrument": "1.9.1", + "istanbul-lib-report": "1.1.2", + "istanbul-lib-source-maps": "1.2.2", + "istanbul-reports": "1.1.3", + "md5-hex": "1.3.0", + "merge-source-map": "1.0.4", + "micromatch": "2.3.11", + "mkdirp": "0.5.1", + "resolve-from": "2.0.0", + "rimraf": "2.6.2", + "signal-exit": "3.0.2", + "spawn-wrap": "1.4.2", + "test-exclude": "4.1.1", + "yargs": "10.0.3", + "yargs-parser": "8.0.0" + }, + "dependencies": { + "align-text": { + "version": "0.1.4", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "3.2.2", + "longest": "1.0.1", + "repeat-string": "1.6.1" + } + }, + "amdefine": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "bundled": true, + "dev": true + }, + "append-transform": { + "version": "0.4.0", + "bundled": true, + "dev": true, + "requires": { + "default-require-extensions": "1.0.0" + } + }, + "archy": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "arr-diff": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "arr-flatten": "1.1.0" + } + }, + "arr-flatten": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "array-unique": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "arrify": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "async": { + "version": "1.5.2", + "bundled": true, + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "chalk": "1.1.3", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + } + }, + "babel-generator": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "detect-indent": "4.0.0", + "jsesc": "1.3.0", + "lodash": "4.17.4", + "source-map": "0.5.7", + "trim-right": "1.0.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-runtime": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "core-js": "2.5.3", + "regenerator-runtime": "0.11.1" + } + }, + "babel-template": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "lodash": "4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "debug": "2.6.9", + "globals": "9.18.0", + "invariant": "2.2.2", + "lodash": "4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "bundled": true, + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "esutils": "2.0.2", + "lodash": "4.17.4", + "to-fast-properties": "1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "bundled": true, + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.8", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "1.8.5", + "bundled": true, + "dev": true, + "requires": { + "expand-range": "1.8.2", + "preserve": "0.2.0", + "repeat-element": "1.1.2" + } + }, + "builtin-modules": { + "version": "1.1.1", + "bundled": true, + "dev": true + }, + "caching-transform": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "md5-hex": "1.3.0", + "mkdirp": "0.5.1", + "write-file-atomic": "1.3.4" + } + }, + "camelcase": { + "version": "1.2.1", + "bundled": true, + "dev": true, + "optional": true + }, + "center-align": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "align-text": "0.1.4", + "lazy-cache": "1.0.4" + } + }, + "chalk": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "cliui": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "center-align": "0.1.3", + "right-align": "0.1.3", + "wordwrap": "0.0.2" + }, + "dependencies": { + "wordwrap": { + "version": "0.0.2", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "commondir": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "convert-source-map": { + "version": "1.5.1", + "bundled": true, + "dev": true + }, + "core-js": { + "version": "2.5.3", + "bundled": true, + "dev": true + }, + "cross-spawn": { + "version": "4.0.2", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "4.1.1", + "which": "1.3.0" + } + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "debug-log": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "decamelize": { + "version": "1.2.0", + "bundled": true, + "dev": true + }, + "default-require-extensions": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "strip-bom": "2.0.0" + } + }, + "detect-indent": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "repeating": "2.0.1" + } + }, + "error-ex": { + "version": "1.3.1", + "bundled": true, + "dev": true, + "requires": { + "is-arrayish": "0.2.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "bundled": true, + "dev": true + }, + "esutils": { + "version": "2.0.2", + "bundled": true, + "dev": true + }, + "execa": { + "version": "0.7.0", + "bundled": true, + "dev": true, + "requires": { + "cross-spawn": "5.1.0", + "get-stream": "3.0.0", + "is-stream": "1.1.0", + "npm-run-path": "2.0.2", + "p-finally": "1.0.0", + "signal-exit": "3.0.2", + "strip-eof": "1.0.0" + }, + "dependencies": { + "cross-spawn": { + "version": "5.1.0", + "bundled": true, + "dev": true, + "requires": { + "lru-cache": "4.1.1", + "shebang-command": "1.2.0", + "which": "1.3.0" + } + } + } + }, + "expand-brackets": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "is-posix-bracket": "0.1.1" + } + }, + "expand-range": { + "version": "1.8.2", + "bundled": true, + "dev": true, + "requires": { + "fill-range": "2.2.3" + } + }, + "extglob": { + "version": "0.3.2", + "bundled": true, + "dev": true, + "requires": { + "is-extglob": "1.0.0" + } + }, + "filename-regex": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "fill-range": { + "version": "2.2.3", + "bundled": true, + "dev": true, + "requires": { + "is-number": "2.1.0", + "isobject": "2.1.0", + "randomatic": "1.1.7", + "repeat-element": "1.1.2", + "repeat-string": "1.6.1" + } + }, + "find-cache-dir": { + "version": "0.1.1", + "bundled": true, + "dev": true, + "requires": { + "commondir": "1.0.1", + "mkdirp": "0.5.1", + "pkg-dir": "1.0.0" + } + }, + "find-up": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "locate-path": "2.0.0" + } + }, + "for-in": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "for-own": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "requires": { + "for-in": "1.0.2" + } + }, + "foreground-child": { + "version": "1.5.6", + "bundled": true, + "dev": true, + "requires": { + "cross-spawn": "4.0.2", + "signal-exit": "3.0.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "get-caller-file": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "requires": { + "fs.realpath": "1.0.0", + "inflight": "1.0.6", + "inherits": "2.0.3", + "minimatch": "3.0.4", + "once": "1.4.0", + "path-is-absolute": "1.0.1" + } + }, + "glob-base": { + "version": "0.3.0", + "bundled": true, + "dev": true, + "requires": { + "glob-parent": "2.0.0", + "is-glob": "2.0.1" + } + }, + "glob-parent": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-glob": "2.0.1" + } + }, + "globals": { + "version": "9.18.0", + "bundled": true, + "dev": true + }, + "graceful-fs": { + "version": "4.1.11", + "bundled": true, + "dev": true + }, + "handlebars": { + "version": "4.0.11", + "bundled": true, + "dev": true, + "requires": { + "async": "1.5.2", + "optimist": "0.6.1", + "source-map": "0.4.4", + "uglify-js": "2.8.29" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "bundled": true, + "dev": true, + "requires": { + "amdefine": "1.0.1" + } + } + } + }, + "has-ansi": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "has-flag": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "hosted-git-info": { + "version": "2.5.0", + "bundled": true, + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "bundled": true, + "dev": true + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "requires": { + "once": "1.4.0", + "wrappy": "1.0.2" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "invariant": { + "version": "2.2.2", + "bundled": true, + "dev": true, + "requires": { + "loose-envify": "1.3.1" + } + }, + "invert-kv": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "is-arrayish": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "is-buffer": { + "version": "1.1.6", + "bundled": true, + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "builtin-modules": "1.1.1" + } + }, + "is-dotfile": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "is-equal-shallow": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "requires": { + "is-primitive": "2.0.0" + } + }, + "is-extendable": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "is-extglob": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "is-glob": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-extglob": "1.0.0" + } + }, + "is-number": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "3.2.2" + } + }, + "is-posix-bracket": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "is-primitive": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "is-utf8": { + "version": "0.2.1", + "bundled": true, + "dev": true + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "isexe": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "isobject": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "isarray": "1.0.0" + } + }, + "istanbul-lib-coverage": { + "version": "1.1.1", + "bundled": true, + "dev": true + }, + "istanbul-lib-hook": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "append-transform": "0.4.0" + } + }, + "istanbul-lib-instrument": { + "version": "1.9.1", + "bundled": true, + "dev": true, + "requires": { + "babel-generator": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "istanbul-lib-coverage": "1.1.1", + "semver": "5.4.1" + } + }, + "istanbul-lib-report": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "requires": { + "istanbul-lib-coverage": "1.1.1", + "mkdirp": "0.5.1", + "path-parse": "1.0.5", + "supports-color": "3.2.3" + }, + "dependencies": { + "supports-color": { + "version": "3.2.3", + "bundled": true, + "dev": true, + "requires": { + "has-flag": "1.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "1.2.2", + "bundled": true, + "dev": true, + "requires": { + "debug": "3.1.0", + "istanbul-lib-coverage": "1.1.1", + "mkdirp": "0.5.1", + "rimraf": "2.6.2", + "source-map": "0.5.7" + }, + "dependencies": { + "debug": { + "version": "3.1.0", + "bundled": true, + "dev": true, + "requires": { + "ms": "2.0.0" + } + } + } + }, + "istanbul-reports": { + "version": "1.1.3", + "bundled": true, + "dev": true, + "requires": { + "handlebars": "4.0.11" + } + }, + "js-tokens": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "jsesc": { + "version": "1.3.0", + "bundled": true, + "dev": true + }, + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + }, + "lazy-cache": { + "version": "1.0.4", + "bundled": true, + "dev": true, + "optional": true + }, + "lcid": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "invert-kv": "1.0.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "parse-json": "2.2.0", + "pify": "2.3.0", + "pinkie-promise": "2.0.1", + "strip-bom": "2.0.0" + } + }, + "locate-path": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "p-locate": "2.0.0", + "path-exists": "3.0.0" + }, + "dependencies": { + "path-exists": { + "version": "3.0.0", + "bundled": true, + "dev": true + } + } + }, + "lodash": { + "version": "4.17.4", + "bundled": true, + "dev": true + }, + "longest": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "loose-envify": { + "version": "1.3.1", + "bundled": true, + "dev": true, + "requires": { + "js-tokens": "3.0.2" + } + }, + "lru-cache": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "requires": { + "pseudomap": "1.0.2", + "yallist": "2.1.2" + } + }, + "md5-hex": { + "version": "1.3.0", + "bundled": true, + "dev": true, + "requires": { + "md5-o-matic": "0.1.1" + } + }, + "md5-o-matic": { + "version": "0.1.1", + "bundled": true, + "dev": true + }, + "mem": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "mimic-fn": "1.1.0" + } + }, + "merge-source-map": { + "version": "1.0.4", + "bundled": true, + "dev": true, + "requires": { + "source-map": "0.5.7" + } + }, + "micromatch": { + "version": "2.3.11", + "bundled": true, + "dev": true, + "requires": { + "arr-diff": "2.0.0", + "array-unique": "0.2.1", + "braces": "1.8.5", + "expand-brackets": "0.1.5", + "extglob": "0.3.2", + "filename-regex": "2.0.1", + "is-extglob": "1.0.0", + "is-glob": "2.0.1", + "kind-of": "3.2.2", + "normalize-path": "2.1.1", + "object.omit": "2.0.1", + "parse-glob": "3.0.4", + "regex-cache": "0.4.4" + } + }, + "mimic-fn": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "normalize-package-data": { + "version": "2.4.0", + "bundled": true, + "dev": true, + "requires": { + "hosted-git-info": "2.5.0", + "is-builtin-module": "1.0.0", + "semver": "5.4.1", + "validate-npm-package-license": "3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "remove-trailing-separator": "1.1.0" + } + }, + "npm-run-path": { + "version": "2.0.2", + "bundled": true, + "dev": true, + "requires": { + "path-key": "2.0.1" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true + }, + "object.omit": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "for-own": "0.1.5", + "is-extendable": "0.1.1" + } + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1.0.2" + } + }, + "optimist": { + "version": "0.6.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8", + "wordwrap": "0.0.3" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "os-locale": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "execa": "0.7.0", + "lcid": "1.0.0", + "mem": "1.1.0" + } + }, + "p-finally": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "p-limit": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "p-locate": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "p-limit": "1.1.0" + } + }, + "parse-glob": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "glob-base": "0.3.0", + "is-dotfile": "1.0.3", + "is-extglob": "1.0.0", + "is-glob": "2.0.1" + } + }, + "parse-json": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "requires": { + "error-ex": "1.3.1" + } + }, + "path-exists": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "pinkie-promise": "2.0.1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "path-key": { + "version": "2.0.1", + "bundled": true, + "dev": true + }, + "path-parse": { + "version": "1.0.5", + "bundled": true, + "dev": true + }, + "path-type": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "pify": "2.3.0", + "pinkie-promise": "2.0.1" + } + }, + "pify": { + "version": "2.3.0", + "bundled": true, + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "bundled": true, + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "pinkie": "2.0.4" + } + }, + "pkg-dir": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "find-up": "1.1.2" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + } + } + }, + "preserve": { + "version": "0.2.0", + "bundled": true, + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "randomatic": { + "version": "1.1.7", + "bundled": true, + "dev": true, + "requires": { + "is-number": "3.0.0", + "kind-of": "4.0.0" + }, + "dependencies": { + "is-number": { + "version": "3.0.0", + "bundled": true, + "dev": true, + "requires": { + "kind-of": "3.2.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "kind-of": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-buffer": "1.1.6" + } + } + } + }, + "read-pkg": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "requires": { + "load-json-file": "1.1.0", + "normalize-package-data": "2.4.0", + "path-type": "1.1.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "requires": { + "find-up": "1.1.2", + "read-pkg": "1.1.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "requires": { + "path-exists": "2.1.0", + "pinkie-promise": "2.0.1" + } + } + } + }, + "regenerator-runtime": { + "version": "0.11.1", + "bundled": true, + "dev": true + }, + "regex-cache": { + "version": "0.4.4", + "bundled": true, + "dev": true, + "requires": { + "is-equal-shallow": "0.1.3" + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "repeat-element": { + "version": "1.1.2", + "bundled": true, + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "bundled": true, + "dev": true + }, + "repeating": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "requires": { + "is-finite": "1.0.2" + } + }, + "require-directory": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "resolve-from": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "right-align": { + "version": "0.1.3", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "align-text": "0.1.4" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "requires": { + "glob": "7.1.2" + } + }, + "semver": { + "version": "5.4.1", + "bundled": true, + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "shebang-command": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "requires": { + "shebang-regex": "1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true + }, + "slide": { + "version": "1.1.6", + "bundled": true, + "dev": true + }, + "source-map": { + "version": "0.5.7", + "bundled": true, + "dev": true + }, + "spawn-wrap": { + "version": "1.4.2", + "bundled": true, + "dev": true, + "requires": { + "foreground-child": "1.5.6", + "mkdirp": "0.5.1", + "os-homedir": "1.0.2", + "rimraf": "2.6.2", + "signal-exit": "3.0.2", + "which": "1.3.0" + } + }, + "spdx-correct": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "spdx-license-ids": "1.2.2" + } + }, + "spdx-expression-parse": { + "version": "1.0.4", + "bundled": true, + "dev": true + }, + "spdx-license-ids": { + "version": "1.2.2", + "bundled": true, + "dev": true + }, + "string-width": { + "version": "2.1.1", + "bundled": true, + "dev": true, + "requires": { + "is-fullwidth-code-point": "2.0.0", + "strip-ansi": "4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "bundled": true, + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "3.0.0" + } + } + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "strip-bom": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "requires": { + "is-utf8": "0.2.1" + } + }, + "strip-eof": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "supports-color": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "test-exclude": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "requires": { + "arrify": "1.0.1", + "micromatch": "2.3.11", + "object-assign": "4.1.1", + "read-pkg-up": "1.0.1", + "require-main-filename": "1.0.1" + } + }, + "to-fast-properties": { + "version": "1.0.3", + "bundled": true, + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "uglify-js": { + "version": "2.8.29", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "source-map": "0.5.7", + "uglify-to-browserify": "1.0.2", + "yargs": "3.10.0" + }, + "dependencies": { + "yargs": { + "version": "3.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "camelcase": "1.2.1", + "cliui": "2.1.0", + "decamelize": "1.2.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "validate-npm-package-license": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "spdx-correct": "1.0.2", + "spdx-expression-parse": "1.0.4" + } + }, + "which": { + "version": "1.3.0", + "bundled": true, + "dev": true, + "requires": { + "isexe": "2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "bundled": true, + "dev": true + }, + "window-size": { + "version": "0.1.0", + "bundled": true, + "dev": true, + "optional": true + }, + "wordwrap": { + "version": "0.0.3", + "bundled": true, + "dev": true + }, + "wrap-ansi": { + "version": "2.1.0", + "bundled": true, + "dev": true, + "requires": { + "string-width": "1.0.2", + "strip-ansi": "3.0.1" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "write-file-atomic": { + "version": "1.3.4", + "bundled": true, + "dev": true, + "requires": { + "graceful-fs": "4.1.11", + "imurmurhash": "0.1.4", + "slide": "1.1.6" + } + }, + "y18n": { + "version": "3.2.1", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "2.1.2", + "bundled": true, + "dev": true + }, + "yargs": { + "version": "10.0.3", + "bundled": true, + "dev": true, + "requires": { + "cliui": "3.2.0", + "decamelize": "1.2.0", + "find-up": "2.1.0", + "get-caller-file": "1.0.2", + "os-locale": "2.1.0", + "require-directory": "2.1.1", + "require-main-filename": "1.0.1", + "set-blocking": "2.0.0", + "string-width": "2.1.1", + "which-module": "2.0.0", + "y18n": "3.2.1", + "yargs-parser": "8.0.0" + }, + "dependencies": { + "cliui": { + "version": "3.2.0", + "bundled": true, + "dev": true, + "requires": { + "string-width": "1.0.2", + "strip-ansi": "3.0.1", + "wrap-ansi": "2.1.0" + }, + "dependencies": { + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "1.1.0", + "is-fullwidth-code-point": "1.0.0", + "strip-ansi": "3.0.1" + } + } + } + } + } + }, + "yargs-parser": { + "version": "8.0.0", + "bundled": true, + "dev": true, + "requires": { + "camelcase": "4.1.0" + }, + "dependencies": { + "camelcase": { + "version": "4.1.0", + "bundled": true, + "dev": true + } + } + } + } + }, "oauth-sign": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.3.0.tgz", @@ -10668,9 +12429,9 @@ "integrity": "sha1-gdgm9F+pyLIBH0zXoP5ZfSQdRCs=" }, "redis-mock": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/redis-mock/-/redis-mock-0.20.0.tgz", - "integrity": "sha1-lKOVhlurvOv1OLa1mUFyPkgHMBA=", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/redis-mock/-/redis-mock-0.21.0.tgz", + "integrity": "sha512-AkLaD8JFrcQKFOklYsbocdqZ2hof69VQKOhH4uh5hAQuu0b/TaSlHyM2LSmE+8ZOYc6dNJzuYkQ6vQ1CRM122w==", "dev": true }, "redis-parser": { @@ -13231,6 +14992,33 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true }, + "wreck": { + "version": "12.5.1", + "resolved": "https://registry.npmjs.org/wreck/-/wreck-12.5.1.tgz", + "integrity": "sha512-l5DUGrc+yDyIflpty1x9XuMj1ehVjC/dTbF3/BasOO77xk0EdEa4M/DuOY8W88MQDAD0fEDqyjc8bkIMHd2E9A==", + "dev": true, + "requires": { + "boom": "5.2.0", + "hoek": "4.2.0" + }, + "dependencies": { + "boom": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-5.2.0.tgz", + "integrity": "sha512-Z5BTk6ZRe4tXXQlkqftmsAUANpXmuwlsF5Oov8ThoMbQRzdGTA1ngYRW160GexgOgjsFOKJz0LYhoNi+2AMBUw==", + "dev": true, + "requires": { + "hoek": "4.2.0" + } + }, + "hoek": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.0.tgz", + "integrity": "sha512-v0XCLxICi9nPfYrS9RL8HbYnXi9obYAeLbSP00BmnZwCK9+Ih9WOjoZ8YoHCoav2csqn4FOz4Orldsy2dmDwmQ==", + "dev": true + } + } + }, "write": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", diff --git a/package.json b/package.json index e4b12d16..4b1f3594 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,10 @@ "contributors": "git shortlog -s | awk -F\\t '{print $2}' > CONTRIBUTORS", "release": "npm-run-all contributors changelog", "test": "mocha test/unit", + "test:ci": "nyc mocha --reporter=min test/unit", "start": "cross-env NODE_ENV=development webpack-dev-server", - "prod": "node server/prod.js" + "prod": "node server/prod.js", + "cover": "nyc --reporter=html mocha test/unit" }, "lint-staged": { "*.js": [ @@ -39,6 +41,12 @@ "git add" ] }, + "nyc": { + "reporter": [ + "text-summary" + ], + "cache": true + }, "engines": { "node": ">=8.2.0" }, @@ -73,11 +81,13 @@ "mocha": "^5.0.0", "nanobus": "^4.3.2", "npm-run-all": "^4.1.2", + "nsp": "^3.1.0", + "nyc": "^11.4.1", "postcss-loader": "^2.1.0", "prettier": "^1.10.2", "proxyquire": "^1.8.0", "raven-js": "^3.22.1", - "redis-mock": "^0.20.0", + "redis-mock": "^0.21.0", "require-from-string": "^2.0.1", "rimraf": "^2.6.2", "selenium-webdriver": "^3.6.0", diff --git a/server/languages.js b/server/languages.js deleted file mode 100644 index d9121eef..00000000 --- a/server/languages.js +++ /dev/null @@ -1,16 +0,0 @@ -const { availableLanguages } = require('../package.json'); -const config = require('./config'); -const fs = require('fs'); -const path = require('path'); - -function allLangs() { - return fs.readdirSync( - path.join(__dirname, '..', 'dist', 'public', 'locales') - ); -} - -if (config.l10n_dev) { - module.exports = allLangs(); -} else { - module.exports = availableLanguages; -} diff --git a/server/metadata.js b/server/metadata.js new file mode 100644 index 00000000..1c599316 --- /dev/null +++ b/server/metadata.js @@ -0,0 +1,13 @@ +class Metadata { + constructor(obj) { + this.dl = +obj.dl || 0; + this.dlimit = +obj.dlimit || 1; + this.pwd = String(obj.pwd) === 'true'; + this.owner = obj.owner; + this.metadata = obj.metadata; + this.auth = obj.auth; + this.nonce = obj.nonce; + } +} + +module.exports = Metadata; diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 00000000..b1a1607d --- /dev/null +++ b/server/middleware/auth.js @@ -0,0 +1,38 @@ +const crypto = require('crypto'); +const storage = require('../storage'); + +module.exports = async function(req, res, next) { + const id = req.params.id; + if (id && req.header('Authorization')) { + try { + const auth = req.header('Authorization').split(' ')[1]; + const meta = await storage.metadata(id); + if (!meta) { + return res.sendStatus(404); + } + const hmac = crypto.createHmac( + 'sha256', + Buffer.from(meta.auth, 'base64') + ); + hmac.update(Buffer.from(meta.nonce, 'base64')); + const verifyHash = hmac.digest(); + if (verifyHash.equals(Buffer.from(auth, 'base64'))) { + req.nonce = crypto.randomBytes(16).toString('base64'); + storage.setField(id, 'nonce', req.nonce); + res.set('WWW-Authenticate', `send-v1 ${req.nonce}`); + req.authorized = true; + req.meta = meta; + } else { + res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); + req.authorized = false; + } + } catch (e) { + req.authorized = false; + } + } + if (req.authorized) { + next(); + } else { + res.sendStatus(401); + } +}; diff --git a/server/middleware/language.js b/server/middleware/language.js new file mode 100644 index 00000000..88282642 --- /dev/null +++ b/server/middleware/language.js @@ -0,0 +1,40 @@ +const { availableLanguages } = require('../../package.json'); +const config = require('../config'); +const fs = require('fs'); +const path = require('path'); +const { negotiateLanguages } = require('fluent-langneg'); +const langData = require('cldr-core/supplemental/likelySubtags.json'); +const acceptLanguages = /(([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;q=[0-1](\.[0-9]+)?)?/g; + +function allLangs() { + return fs.readdirSync( + path.join(__dirname, '..', '..', 'dist', 'public', 'locales') + ); +} + +const languages = config.l10n_dev ? allLangs() : availableLanguages; + +module.exports = function(req, res, next) { + const header = req.headers['accept-language'] || 'en-US'; + if (header.length > 255) { + req.language = 'en-US'; + return next(); + } + const langs = header.replace(/\s/g, '').match(acceptLanguages); + const preferred = langs + .map(l => { + const parts = l.split(';'); + return { + locale: parts[0], + q: parts[1] ? parseFloat(parts[1].split('=')[1]) : 1 + }; + }) + .sort((a, b) => b.q - a.q) + .map(x => x.locale); + req.language = negotiateLanguages(preferred, languages, { + strategy: 'lookup', + likelySubtags: langData.supplemental.likelySubtags, + defaultLocale: 'en-US' + })[0]; + next(); +}; diff --git a/server/middleware/owner.js b/server/middleware/owner.js new file mode 100644 index 00000000..eb66562d --- /dev/null +++ b/server/middleware/owner.js @@ -0,0 +1,22 @@ +const storage = require('../storage'); + +module.exports = async function(req, res, next) { + const id = req.params.id; + const ownerToken = req.body.owner_token; + if (id && ownerToken) { + try { + req.meta = await storage.metadata(id); + if (!req.meta) { + return res.sendStatus(404); + } + req.authorized = req.meta.owner === ownerToken; + } catch (e) { + req.authorized = false; + } + } + if (req.authorized) { + next(); + } else { + res.sendStatus(401); + } +}; diff --git a/server/routes/delete.js b/server/routes/delete.js index 080b7a69..71ada5f9 100644 --- a/server/routes/delete.js +++ b/server/routes/delete.js @@ -1,20 +1,9 @@ const storage = require('../storage'); module.exports = async function(req, res) { - const id = req.params.id; - - const ownerToken = req.body.owner_token || req.body.delete_token; - - if (!ownerToken) { - res.sendStatus(404); - return; - } - try { - const err = await storage.delete(id, ownerToken); - if (!err) { - res.sendStatus(200); - } + await storage.del(req.params.id); + res.sendStatus(200); } catch (e) { res.sendStatus(404); } diff --git a/server/routes/download.js b/server/routes/download.js index 56a0f23e..bfd667ed 100644 --- a/server/routes/download.js +++ b/server/routes/download.js @@ -1,39 +1,26 @@ const storage = require('../storage'); const mozlog = require('../log'); const log = mozlog('send.download'); -const crypto = require('crypto'); module.exports = async function(req, res) { const id = req.params.id; - try { - const auth = req.header('Authorization').split(' ')[1]; - const meta = await storage.metadata(id); - const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64')); - hmac.update(Buffer.from(meta.nonce, 'base64')); - const verifyHash = hmac.digest(); - if (!verifyHash.equals(Buffer.from(auth, 'base64'))) { - res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); - return res.sendStatus(401); - } - const nonce = crypto.randomBytes(16).toString('base64'); - storage.setField(id, 'nonce', nonce); + const meta = req.meta; const contentLength = await storage.length(id); res.writeHead(200, { 'Content-Disposition': 'attachment', 'Content-Type': 'application/octet-stream', 'Content-Length': contentLength, - 'X-File-Metadata': meta.metadata, - 'WWW-Authenticate': `send-v1 ${nonce}` + 'WWW-Authenticate': `send-v1 ${req.nonce}` }); const file_stream = storage.get(id); file_stream.on('end', async () => { - const dl = (+meta.dl || 0) + 1; - const dlimit = +meta.dlimit || 1; + const dl = meta.dl + 1; + const dlimit = meta.dlimit; try { if (dl >= dlimit) { - await storage.forceDelete(id); + await storage.del(id); } else { await storage.setField(id, 'dl', dl); } @@ -41,7 +28,6 @@ module.exports = async function(req, res) { log.info('StorageError:', id); } }); - file_stream.pipe(res); } catch (e) { res.sendStatus(404); diff --git a/server/routes/exists.js b/server/routes/exists.js index c8039887..badb32c3 100644 --- a/server/routes/exists.js +++ b/server/routes/exists.js @@ -1,13 +1,11 @@ const storage = require('../storage'); module.exports = async (req, res) => { - const id = req.params.id; - try { - const meta = await storage.metadata(id); + const meta = await storage.metadata(req.params.id); res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); res.send({ - password: meta.pwd !== '0' + password: meta.pwd }); } catch (e) { res.sendStatus(404); diff --git a/server/routes/index.js b/server/routes/index.js index f2c7e79d..1d022907 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -1,41 +1,22 @@ const busboy = require('connect-busboy'); const helmet = require('helmet'); const bodyParser = require('body-parser'); -const languages = require('../languages'); const storage = require('../storage'); const config = require('../config'); +const auth = require('../middleware/auth'); +const owner = require('../middleware/owner'); +const language = require('../middleware/language'); const pages = require('./pages'); -const { negotiateLanguages } = require('fluent-langneg'); + const IS_DEV = config.env === 'development'; -const acceptLanguages = /(([a-zA-Z]+(-[a-zA-Z0-9]+){0,2})|\*)(;q=[0-1](\.[0-9]+)?)?/g; -const langData = require('cldr-core/supplemental/likelySubtags.json'); -const idregx = '([0-9a-fA-F]{10})'; +const ID_REGEX = '([0-9a-fA-F]{10})'; +const uploader = busboy({ + limits: { + fileSize: config.max_file_size + } +}); module.exports = function(app) { - app.use(function(req, res, next) { - const header = req.headers['accept-language'] || 'en-US'; - if (header.length > 255) { - req.language = 'en-US'; - return next(); - } - const langs = header.replace(/\s/g, '').match(acceptLanguages); - const preferred = langs - .map(l => { - const parts = l.split(';'); - return { - locale: parts[0], - q: parts[1] ? parseFloat(parts[1].split('=')[1]) : 1 - }; - }) - .sort((a, b) => b.q - a.q) - .map(x => x.locale); - req.language = negotiateLanguages(preferred, languages, { - strategy: 'lookup', - likelySubtags: langData.supplemental.likelySubtags, - defaultLocale: 'en-US' - })[0]; - next(); - }); app.use(helmet()); app.use( helmet.hsts({ @@ -69,34 +50,27 @@ module.exports = function(app) { }) ); } - app.use( - busboy({ - limits: { - fileSize: config.max_file_size - } - }) - ); app.use(function(req, res, next) { res.set('Pragma', 'no-cache'); res.set('Cache-Control', 'no-cache'); next(); }); app.use(bodyParser.json()); - app.get('/', pages.index); - app.get('/legal', pages.legal); + app.get('/', language, pages.index); + app.get('/legal', language, pages.legal); app.get('/jsconfig.js', require('./jsconfig')); - app.get(`/share/:id${idregx}`, pages.blank); - app.get(`/download/:id${idregx}`, pages.download); - app.get('/completed', pages.blank); - app.get('/unsupported/:reason', pages.unsupported); - app.get(`/api/download/:id${idregx}`, require('./download')); - app.get(`/api/exists/:id${idregx}`, require('./exists')); - app.get(`/api/metadata/:id${idregx}`, require('./metadata')); - app.post('/api/upload', require('./upload')); - app.post(`/api/delete/:id${idregx}`, require('./delete')); - app.post(`/api/password/:id${idregx}`, require('./password')); - app.post(`/api/params/:id${idregx}`, require('./params')); - app.post(`/api/info/:id${idregx}`, require('./info')); + app.get(`/share/:id${ID_REGEX}`, language, pages.blank); + app.get(`/download/:id${ID_REGEX}`, language, pages.download); + app.get('/completed', language, pages.blank); + app.get('/unsupported/:reason', language, pages.unsupported); + app.get(`/api/download/:id${ID_REGEX}`, auth, require('./download')); + app.get(`/api/exists/:id${ID_REGEX}`, require('./exists')); + app.get(`/api/metadata/:id${ID_REGEX}`, auth, require('./metadata')); + app.post('/api/upload', uploader, require('./upload')); + app.post(`/api/delete/:id${ID_REGEX}`, owner, require('./delete')); + app.post(`/api/password/:id${ID_REGEX}`, owner, require('./password')); + app.post(`/api/params/:id${ID_REGEX}`, owner, require('./params')); + app.post(`/api/info/:id${ID_REGEX}`, owner, require('./info')); app.get('/__version__', function(req, res) { res.sendFile(require.resolve('../../dist/version.json')); diff --git a/server/routes/info.js b/server/routes/info.js index f133c35c..b993706d 100644 --- a/server/routes/info.js +++ b/server/routes/info.js @@ -1,21 +1,11 @@ const storage = require('../storage'); module.exports = async function(req, res) { - const id = req.params.id; - const ownerToken = req.body.owner_token; - if (!ownerToken) { - return res.sendStatus(400); - } - try { - const meta = await storage.metadata(id); - if (meta.owner !== ownerToken) { - return res.sendStatus(400); - } - const ttl = await storage.ttl(id); + const ttl = await storage.ttl(req.params.id); return res.send({ - dlimit: +meta.dlimit, - dtotal: +meta.dl, + dlimit: +req.meta.dlimit, + dtotal: +req.meta.dl, ttl }); } catch (e) { diff --git a/server/routes/metadata.js b/server/routes/metadata.js index a99942d2..a671cee6 100644 --- a/server/routes/metadata.js +++ b/server/routes/metadata.js @@ -1,28 +1,14 @@ const storage = require('../storage'); -const crypto = require('crypto'); module.exports = async function(req, res) { const id = req.params.id; - + const meta = req.meta; try { - const auth = req.header('Authorization').split(' ')[1]; - const meta = await storage.metadata(id); - const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64')); - hmac.update(Buffer.from(meta.nonce, 'base64')); - const verifyHash = hmac.digest(); - if (!verifyHash.equals(Buffer.from(auth, 'base64'))) { - res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`); - return res.sendStatus(401); - } - const nonce = crypto.randomBytes(16).toString('base64'); - storage.setField(id, 'nonce', nonce); - res.set('WWW-Authenticate', `send-v1 ${nonce}`); - const size = await storage.length(id); const ttl = await storage.ttl(id); res.send({ metadata: meta.metadata, - finalDownload: +meta.dl + 1 === +meta.dlimit, + finalDownload: meta.dl + 1 === meta.dlimit, size, ttl }); diff --git a/server/routes/params.js b/server/routes/params.js index 90219d93..3cb75460 100644 --- a/server/routes/params.js +++ b/server/routes/params.js @@ -1,23 +1,13 @@ const storage = require('../storage'); -module.exports = async function(req, res) { - const id = req.params.id; - const ownerToken = req.body.owner_token; - if (!ownerToken) { - return res.sendStatus(400); - } - +module.exports = function(req, res) { const dlimit = req.body.dlimit; if (!dlimit || dlimit > 20) { return res.sendStatus(400); } try { - const meta = await storage.metadata(id); - if (meta.owner !== ownerToken) { - return res.sendStatus(400); - } - storage.setField(id, 'dlimit', dlimit); + storage.setField(req.params.id, 'dlimit', dlimit); res.sendStatus(200); } catch (e) { res.sendStatus(404); diff --git a/server/routes/password.js b/server/routes/password.js index 2be6fe9a..bf2f5679 100644 --- a/server/routes/password.js +++ b/server/routes/password.js @@ -1,23 +1,15 @@ const storage = require('../storage'); -module.exports = async function(req, res) { +module.exports = function(req, res) { const id = req.params.id; - const ownerToken = req.body.owner_token; - if (!ownerToken) { - return res.sendStatus(404); - } const auth = req.body.auth; if (!auth) { return res.sendStatus(400); } try { - const meta = await storage.metadata(id); - if (meta.owner !== ownerToken) { - return res.sendStatus(404); - } storage.setField(id, 'auth', auth); - storage.setField(id, 'pwd', 1); + storage.setField(id, 'pwd', true); res.sendStatus(200); } catch (e) { return res.sendStatus(404); diff --git a/server/routes/upload.js b/server/routes/upload.js index 6dac96ae..e566bdc8 100644 --- a/server/routes/upload.js +++ b/server/routes/upload.js @@ -14,12 +14,8 @@ module.exports = function(req, res) { } const owner = crypto.randomBytes(10).toString('hex'); const meta = { - dlimit: 1, - dl: 0, owner, - delete: owner, // delete is deprecated metadata, - pwd: 0, auth: auth.split(' ')[1], nonce: crypto.randomBytes(16).toString('base64') }; @@ -47,7 +43,7 @@ module.exports = function(req, res) { req.on('close', async err => { try { - await storage.forceDelete(newId); + await storage.del(newId); } catch (e) { log.info('DeleteError:', newId); } diff --git a/server/storage.js b/server/storage.js deleted file mode 100644 index 35ec6418..00000000 --- a/server/storage.js +++ /dev/null @@ -1,285 +0,0 @@ -const AWS = require('aws-sdk'); -const s3 = new AWS.S3(); -const mkdirp = require('mkdirp'); - -const config = require('./config'); -const fs = require('fs'); -const path = require('path'); - -const mozlog = require('./log'); - -const log = mozlog('send.storage'); - -const redis_lib = - config.env === 'development' && config.redis_host === 'localhost' - ? 'redis-mock' - : 'redis'; - -const redis = require(redis_lib); -const redis_client = redis.createClient({ - host: config.redis_host, - connect_timeout: 10000 -}); - -redis_client.on('error', err => { - log.error('Redis:', err); -}); - -const fileDir = config.file_dir; - -if (config.s3_bucket) { - module.exports = { - exists: exists, - ttl: ttl, - length: awsLength, - get: awsGet, - set: awsSet, - setField: setField, - delete: awsDelete, - forceDelete: awsForceDelete, - ping: awsPing, - flushall: flushall, - quit: quit, - metadata - }; -} else { - mkdirp.sync(config.file_dir); - log.info('fileDir', fileDir); - module.exports = { - exists: exists, - ttl: ttl, - length: localLength, - get: localGet, - set: localSet, - setField: setField, - delete: localDelete, - forceDelete: localForceDelete, - ping: localPing, - flushall: flushall, - quit: quit, - metadata - }; -} - -if (config.redis_event_expire) { - const forceDelete = config.s3_bucket ? awsForceDelete : localForceDelete; - const redis_sub = redis_client.duplicate(); - const subKey = '__keyevent@0__:expired'; - redis_sub.psubscribe(subKey, function() { - log.info('Redis:', 'subscribed to expired key events'); - }); - - redis_sub.on('pmessage', function(channel, message, id) { - log.info('RedisExpired:', id); - forceDelete(id); - }); -} - -function flushall() { - redis_client.flushdb(); -} - -function quit() { - redis_client.quit(); -} - -function metadata(id) { - return new Promise((resolve, reject) => { - redis_client.hgetall(id, (err, reply) => { - if (err || !reply) { - return reject(err); - } - resolve(reply); - }); - }); -} - -function ttl(id) { - return new Promise((resolve, reject) => { - redis_client.ttl(id, (err, reply) => { - if (err || !reply) { - return reject(err); - } - resolve(reply * 1000); - }); - }); -} - -function exists(id) { - return new Promise((resolve, reject) => { - redis_client.exists(id, (rediserr, reply) => { - if (reply === 1 && !rediserr) { - resolve(); - } else { - reject(rediserr); - } - }); - }); -} - -function setField(id, key, value) { - redis_client.hset(id, key, value); -} - -function localLength(id) { - return new Promise((resolve, reject) => { - try { - resolve(fs.statSync(path.join(fileDir, id)).size); - } catch (err) { - reject(); - } - }); -} - -function localGet(id) { - return fs.createReadStream(path.join(fileDir, id)); -} - -function localSet(newId, file, meta) { - return new Promise((resolve, reject) => { - const filepath = path.join(fileDir, newId); - const fstream = fs.createWriteStream(filepath); - file.pipe(fstream); - file.on('limit', () => { - file.unpipe(fstream); - fstream.destroy(new Error('limit')); - }); - fstream.on('finish', () => { - redis_client.hmset(newId, meta); - redis_client.expire(newId, config.expire_seconds); - log.info('localSet:', 'Upload Finished of ' + newId); - resolve(meta.owner); - }); - - fstream.on('error', err => { - log.error('localSet:', 'Failed upload of ' + newId); - fs.unlinkSync(filepath); - reject(err); - }); - }); -} - -function localDelete(id, ownerToken) { - return new Promise((resolve, reject) => { - redis_client.hget(id, 'delete', (err, reply) => { - if (!reply || ownerToken !== reply) { - reject(); - } else { - redis_client.del(id); - log.info('Deleted:', id); - resolve(fs.unlinkSync(path.join(fileDir, id))); - } - }); - }); -} - -function localForceDelete(id) { - return new Promise((resolve, reject) => { - redis_client.del(id); - resolve(fs.unlinkSync(path.join(fileDir, id))); - }); -} - -function localPing() { - return new Promise((resolve, reject) => { - redis_client.ping(err => { - return err ? reject() : resolve(); - }); - }); -} - -function awsLength(id) { - const params = { - Bucket: config.s3_bucket, - Key: id - }; - return new Promise((resolve, reject) => { - s3.headObject(params, function(err, data) { - if (!err) { - resolve(data.ContentLength); - } else { - reject(); - } - }); - }); -} - -function awsGet(id) { - const params = { - Bucket: config.s3_bucket, - Key: id - }; - - try { - return s3.getObject(params).createReadStream(); - } catch (err) { - return null; - } -} - -function awsSet(newId, file, meta) { - const params = { - Bucket: config.s3_bucket, - Key: newId, - Body: file - }; - let hitLimit = false; - const upload = s3.upload(params); - file.on('limit', () => { - hitLimit = true; - upload.abort(); - }); - return upload.promise().then( - () => { - redis_client.hmset(newId, meta); - redis_client.expire(newId, config.expire_seconds); - }, - err => { - if (hitLimit) { - throw new Error('limit'); - } else { - throw err; - } - } - ); -} - -function awsDelete(id, ownerToken) { - return new Promise((resolve, reject) => { - redis_client.hget(id, 'delete', (err, reply) => { - if (!reply || ownerToken !== reply) { - reject(); - } else { - const params = { - Bucket: config.s3_bucket, - Key: id - }; - - s3.deleteObject(params, function(err, _data) { - redis_client.del(id); - err ? reject(err) : resolve(err); - }); - } - }); - }); -} - -function awsForceDelete(id) { - return new Promise((resolve, reject) => { - const params = { - Bucket: config.s3_bucket, - Key: id - }; - - s3.deleteObject(params, function(err, _data) { - redis_client.del(id); - err ? reject(err) : resolve(); - }); - }); -} - -function awsPing() { - return localPing().then(() => - s3.headBucket({ Bucket: config.s3_bucket }).promise() - ); -} diff --git a/server/storage/fs.js b/server/storage/fs.js new file mode 100644 index 00000000..54fcd53a --- /dev/null +++ b/server/storage/fs.js @@ -0,0 +1,50 @@ +const fs = require('fs'); +const path = require('path'); +const promisify = require('util').promisify; +const mkdirp = require('mkdirp'); + +const stat = promisify(fs.stat); + +class FSStorage { + constructor(config, log) { + this.log = log; + this.dir = config.file_dir; + mkdirp.sync(this.dir); + } + + async length(id) { + const result = await stat(path.join(this.dir, id)); + return result.size; + } + + getStream(id) { + return fs.createReadStream(path.join(this.dir, id)); + } + + set(id, file) { + return new Promise((resolve, reject) => { + const filepath = path.join(this.dir, id); + const fstream = fs.createWriteStream(filepath); + file.pipe(fstream); + file.on('limit', () => { + file.unpipe(fstream); + fstream.destroy(new Error('limit')); + }); + fstream.on('error', err => { + fs.unlinkSync(filepath); + reject(err); + }); + fstream.on('finish', resolve); + }); + } + + del(id) { + return Promise.resolve(fs.unlinkSync(path.join(this.dir, id))); + } + + ping() { + return Promise.resolve(); + } +} + +module.exports = FSStorage; diff --git a/server/storage/index.js b/server/storage/index.js new file mode 100644 index 00000000..9a8100c2 --- /dev/null +++ b/server/storage/index.js @@ -0,0 +1,57 @@ +const config = require('../config'); +const Metadata = require('../metadata'); +const mozlog = require('../log'); +const createRedisClient = require('./redis'); + +class DB { + constructor(config) { + const Storage = config.s3_bucket ? require('./s3') : require('./fs'); + this.log = mozlog('send.storage'); + this.expireSeconds = config.expire_seconds; + this.storage = new Storage(config, this.log); + this.redis = createRedisClient(config); + this.redis.on('error', err => { + this.log.error('Redis:', err); + }); + } + + async ttl(id) { + const result = await this.redis.ttlAsync(id); + return Math.ceil(result) * 1000; + } + + length(id) { + return this.storage.length(id); + } + + get(id) { + return this.storage.getStream(id); + } + + async set(id, file, meta) { + await this.storage.set(id, file); + this.redis.hmset(id, meta); + this.redis.expire(id, this.expireSeconds); + } + + setField(id, key, value) { + this.redis.hset(id, key, value); + } + + del(id) { + this.redis.del(id); + return this.storage.del(id); + } + + async ping() { + await this.redis.pingAsync(); + await this.storage.ping(); + } + + async metadata(id) { + const result = await this.redis.hgetallAsync(id); + return result && new Metadata(result); + } +} + +module.exports = new DB(config); diff --git a/server/storage/redis.js b/server/storage/redis.js new file mode 100644 index 00000000..063c977d --- /dev/null +++ b/server/storage/redis.js @@ -0,0 +1,21 @@ +const promisify = require('util').promisify; + +module.exports = function(config) { + const redis_lib = + config.env === 'development' && config.redis_host === 'localhost' + ? 'redis-mock' + : 'redis'; + + //eslint-disable-next-line security/detect-non-literal-require + const redis = require(redis_lib); + const client = redis.createClient({ + host: config.redis_host, + connect_timeout: 10000 + }); + + client.ttlAsync = promisify(client.ttl); + client.hgetallAsync = promisify(client.hgetall); + client.hgetAsync = promisify(client.hget); + client.pingAsync = promisify(client.ping); + return client; +}; diff --git a/server/storage/s3.js b/server/storage/s3.js new file mode 100644 index 00000000..b77f91b3 --- /dev/null +++ b/server/storage/s3.js @@ -0,0 +1,51 @@ +const AWS = require('aws-sdk'); +const s3 = new AWS.S3(); + +class S3Storage { + constructor(config, log) { + this.bucket = config.s3_bucket; + this.log = log; + } + + async length(id) { + const result = await s3 + .headObject({ Bucket: this.bucket, Key: id }) + .promise(); + return result.ContentLength; + } + + getStream(id) { + return s3.getObject({ Bucket: this.bucket, Key: id }).createReadStream(); + } + + async set(id, file) { + let hitLimit = false; + const upload = s3.upload({ + Bucket: this.bucket, + Key: id, + Body: file + }); + file.on('limit', () => { + hitLimit = true; + upload.abort(); + }); + try { + await upload.promise(); + } catch (e) { + if (hitLimit) { + throw new Error('limit'); + } + throw e; + } + } + + del(id) { + return s3.deleteObject({ Bucket: this.bucket, Key: id }).promise(); + } + + ping() { + return s3.headBucket({ Bucket: this.bucket }).promise(); + } +} + +module.exports = S3Storage; diff --git a/test/unit/auth-tests.js b/test/unit/auth-tests.js new file mode 100644 index 00000000..632007ab --- /dev/null +++ b/test/unit/auth-tests.js @@ -0,0 +1,105 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + metadata: sinon.stub(), + setField: sinon.stub() +}; + +function request(id, auth) { + return { + params: { id }, + header: sinon.stub().returns(auth) + }; +} + +function response() { + return { + sendStatus: sinon.stub(), + set: sinon.stub() + }; +} + +const next = sinon.stub(); + +const storedMeta = { + auth: + 'r9uFxEs9GEVaQR9CJJ0uTKFGhFSOTRjOY2FCLFlCIZ0Cr-VGTVpMGlXDbNR8RMT55trMpSrzWtBVKq1LffOT2g', + nonce: 'FL4oxA7IE1PW8shwFN9qZw==' +}; + +const authMiddleware = proxyquire('../../server/middleware/auth', { + '../storage': storage +}); + +describe('Owner Middleware', function() { + afterEach(function() { + storage.metadata.reset(); + storage.setField.reset(); + next.reset(); + }); + + it('sends a 401 when no auth header is set', async function() { + const req = request('x'); + const res = response(); + await authMiddleware(req, res, next); + sinon.assert.calledWith(res.sendStatus, 401); + sinon.assert.notCalled(next); + }); + + it('sends a 404 when metadata is not found', async function() { + const req = request('x', 'y'); + const res = response(); + await authMiddleware(req, res, next); + sinon.assert.calledWith(res.sendStatus, 404); + sinon.assert.notCalled(next); + }); + + it('sends a 401 when the auth header is invalid base64', async function() { + storage.metadata.returns(Promise.resolve(storedMeta)); + const req = request('x', '1'); + const res = response(); + await authMiddleware(req, res, next); + sinon.assert.calledWith(res.sendStatus, 401); + sinon.assert.notCalled(next); + }); + + it('authenticates when the hashes match', async function() { + storage.metadata.returns(Promise.resolve(storedMeta)); + const req = request( + 'x', + 'send-v1 R7nZk14qJqZXtxpnAtw2uDIRQTRnO1qSO1Q0PiwcNA8' + ); + const res = response(); + await authMiddleware(req, res, next); + sinon.assert.calledOnce(next); + sinon.assert.calledWith(storage.setField, 'x', 'nonce', req.nonce); + sinon.assert.calledWith( + res.set, + 'WWW-Authenticate', + `send-v1 ${req.nonce}` + ); + sinon.assert.notCalled(res.sendStatus); + assert.equal(req.authorized, true); + assert.equal(req.meta, storedMeta); + assert.notEqual(req.nonce, storedMeta.nonce); + }); + + it('sends a 401 when the hashes do not match', async function() { + storage.metadata.returns(Promise.resolve(storedMeta)); + const req = request( + 'x', + 'send-v1 R8nZk14qJqZXtxpnAtw2uDIRQTRnO1qSO1Q0PiwcNA8' + ); + const res = response(); + await authMiddleware(req, res, next); + sinon.assert.calledWith(res.sendStatus, 401); + sinon.assert.calledWith( + res.set, + 'WWW-Authenticate', + `send-v1 ${storedMeta.nonce}` + ); + sinon.assert.notCalled(next); + }); +}); diff --git a/test/unit/aws.storage.test.js b/test/unit/aws.storage.test.js deleted file mode 100644 index 1505a7e7..00000000 --- a/test/unit/aws.storage.test.js +++ /dev/null @@ -1,173 +0,0 @@ -const assert = require('assert'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); -const crypto = require('crypto'); - -const redisStub = {}; -const exists = sinon.stub(); -const hget = sinon.stub(); -const hmset = sinon.stub(); -const expire = sinon.spy(); -const del = sinon.stub(); - -redisStub.createClient = function() { - return { - on: sinon.spy(), - exists: exists, - hget: hget, - hmset: hmset, - expire: expire, - del: del - }; -}; - -const fsStub = {}; -fsStub.statSync = sinon.stub(); -fsStub.createReadStream = sinon.stub(); -fsStub.createWriteStream = sinon.stub(); -fsStub.unlinkSync = sinon.stub(); - -const logStub = {}; -logStub.info = sinon.stub(); -logStub.error = sinon.stub(); - -const s3Stub = {}; -s3Stub.headObject = sinon.stub(); -s3Stub.getObject = sinon.stub(); -s3Stub.upload = sinon.stub(); -s3Stub.deleteObject = sinon.stub(); - -const awsStub = { - S3: function() { - return s3Stub; - } -}; - -const storage = proxyquire('../../server/storage', { - redis: redisStub, - 'redis-mock': redisStub, - fs: fsStub, - './log': function() { - return logStub; - }, - 'aws-sdk': awsStub, - './config': { - s3_bucket: 'test' - } -}); - -describe('Testing Length using aws', function() { - it('Filesize returns properly if id exists', function() { - s3Stub.headObject.callsArgWith(1, null, { ContentLength: 1 }); - return storage - .length('123') - .then(reply => assert.equal(reply, 1)) - .catch(err => assert.fail()); - }); - - it('Filesize fails if the id does not exist', function() { - s3Stub.headObject.callsArgWith(1, new Error(), null); - return storage - .length('123') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Get using aws', function() { - it('Should not error out when the file exists', function() { - const spy = sinon.spy(); - s3Stub.getObject.returns({ - createReadStream: spy - }); - - storage.get('123'); - assert(spy.calledOnce); - }); - - it('Should error when the file does not exist', function() { - const err = function() { - throw new Error(); - }; - const spy = sinon.spy(err); - s3Stub.getObject.returns({ - createReadStream: spy - }); - - assert.equal(storage.get('123'), null); - assert(spy.threw()); - }); -}); - -describe('Testing Set using aws', function() { - beforeEach(function() { - expire.resetHistory(); - }); - - after(function() { - crypto.randomBytes.restore(); - }); - - it('Should pass when the file is successfully uploaded', function() { - const buf = Buffer.alloc(10); - sinon.stub(crypto, 'randomBytes').returns(buf); - s3Stub.upload.returns({ promise: () => Promise.resolve() }); - return storage - .set('123', { on: sinon.stub() }, 'Filename.moz', {}) - .then(() => { - assert(expire.calledOnce); - assert(expire.calledWith('123', 86400)); - }) - .catch(err => assert.fail()); - }); - - it('Should fail if there was an error during uploading', function() { - s3Stub.upload.returns({ promise: () => Promise.reject() }); - return storage - .set('123', { on: sinon.stub() }, 'Filename.moz', 'url.com') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Delete from aws', function() { - it('Returns successfully if the id is deleted off aws', function() { - hget.callsArgWith(2, null, 'delete_token'); - s3Stub.deleteObject.callsArgWith(1, null, {}); - return storage - .delete('file_id', 'delete_token') - .then(_reply => assert(1), err => assert.fail()); - }); - - it('Delete fails if id exists locally but does not in aws', function() { - hget.callsArgWith(2, null, 'delete_token'); - s3Stub.deleteObject.callsArgWith(1, new Error(), {}); - return storage - .delete('file_id', 'delete_token') - .then(_reply => assert.fail(), err => assert(1)); - }); - - it('Delete fails if the delete token does not match', function() { - hget.callsArgWith(2, null, {}); - return storage - .delete('Filename.moz', 'delete_token') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Forced Delete from aws', function() { - it('Deletes properly if id exists', function() { - s3Stub.deleteObject.callsArgWith(1, null, {}); - return storage - .forceDelete('file_id', 'delete_token') - .then(_reply => assert(1), err => assert.fail()); - }); - - it('Deletes fails if id does not exist', function() { - s3Stub.deleteObject.callsArgWith(1, new Error(), {}); - return storage - .forceDelete('file_id') - .then(_reply => assert.fail(), err => assert(1)); - }); -}); diff --git a/test/unit/delete-tests.js b/test/unit/delete-tests.js new file mode 100644 index 00000000..1de32349 --- /dev/null +++ b/test/unit/delete-tests.js @@ -0,0 +1,43 @@ +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + del: sinon.stub() +}; + +function request(id) { + return { + params: { id } + }; +} + +function response() { + return { + sendStatus: sinon.stub() + }; +} + +const delRoute = proxyquire('../../server/routes/delete', { + '../storage': storage +}); + +describe('/api/delete', function() { + afterEach(function() { + storage.del.reset(); + }); + + it('calls storage.del with the id parameter', async function() { + const req = request('x'); + const res = response(); + await delRoute(req, res); + sinon.assert.calledWith(storage.del, 'x'); + sinon.assert.calledWith(res.sendStatus, 200); + }); + + it('sends a 404 on failure', async function() { + storage.del.returns(Promise.reject(new Error())); + const res = response(); + await delRoute(request('x'), res); + sinon.assert.calledWith(res.sendStatus, 404); + }); +}); diff --git a/test/unit/info-tests.js b/test/unit/info-tests.js new file mode 100644 index 00000000..d0a562fa --- /dev/null +++ b/test/unit/info-tests.js @@ -0,0 +1,59 @@ +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + ttl: sinon.stub() +}; + +function request(id, meta) { + return { + params: { id }, + meta + }; +} + +function response() { + return { + sendStatus: sinon.stub(), + send: sinon.stub() + }; +} + +const infoRoute = proxyquire('../../server/routes/info', { + '../storage': storage +}); + +describe('/api/info', function() { + afterEach(function() { + storage.ttl.reset(); + }); + + it('calls storage.ttl with the id parameter', async function() { + const req = request('x'); + const res = response(); + await infoRoute(req, res); + sinon.assert.calledWith(storage.ttl, 'x'); + }); + + it('sends a 404 on failure', async function() { + storage.ttl.returns(Promise.reject(new Error())); + const res = response(); + await infoRoute(request('x'), res); + sinon.assert.calledWith(res.sendStatus, 404); + }); + + it('returns a json object', async function() { + storage.ttl.returns(Promise.resolve(123)); + const meta = { + dlimit: '1', + dl: '0' + }; + const res = response(); + await infoRoute(request('x', meta), res); + sinon.assert.calledWithMatch(res.send, { + dlimit: 1, + dtotal: 0, + ttl: 123 + }); + }); +}); diff --git a/test/unit/language-tests.js b/test/unit/language-tests.js new file mode 100644 index 00000000..1480d15f --- /dev/null +++ b/test/unit/language-tests.js @@ -0,0 +1,67 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const config = { + l10n_dev: false // prod configuration +}; +const pkg = { + availableLanguages: ['en-US', 'fr', 'it', 'es-ES'] +}; + +function request(acceptLang) { + return { + headers: { + 'accept-language': acceptLang + } + }; +} + +const langMiddleware = proxyquire('../../server/middleware/language', { + '../config': config, + '../../package.json': pkg +}); + +describe('Language Middleware', function() { + it('defaults to en-US when no header is present', function() { + const req = request(); + const next = sinon.stub(); + langMiddleware(req, null, next); + assert.equal(req.language, 'en-US'); + sinon.assert.calledOnce(next); + }); + + it('sets req.language to en-US when accept-language > 255 chars', function() { + const accept = Array(257).join('a'); + assert.equal(accept.length, 256); + const req = request(accept); + const next = sinon.stub(); + langMiddleware(req, null, next); + assert.equal(req.language, 'en-US'); + sinon.assert.calledOnce(next); + }); + + it('defaults to en-US when no accept-language is available', function() { + const req = request('fa,cs,ja'); + const next = sinon.stub(); + langMiddleware(req, null, next); + assert.equal(req.language, 'en-US'); + sinon.assert.calledOnce(next); + }); + + it('prefers higher q values', function() { + const req = request('fa;q=0.5, it;q=0.9'); + const next = sinon.stub(); + langMiddleware(req, null, next); + assert.equal(req.language, 'it'); + sinon.assert.calledOnce(next); + }); + + it('uses likely subtags', function() { + const req = request('es-MX'); + const next = sinon.stub(); + langMiddleware(req, null, next); + assert.equal(req.language, 'es-ES'); + sinon.assert.calledOn(next); + }); +}); diff --git a/test/unit/local.storage.test.js b/test/unit/local.storage.test.js deleted file mode 100644 index 495c6e8e..00000000 --- a/test/unit/local.storage.test.js +++ /dev/null @@ -1,163 +0,0 @@ -const assert = require('assert'); -const sinon = require('sinon'); -const proxyquire = require('proxyquire'); - -const redisStub = {}; -const exists = sinon.stub(); -const hget = sinon.stub(); -const hmset = sinon.stub(); -const expire = sinon.stub(); -const del = sinon.stub(); - -redisStub.createClient = function() { - return { - on: sinon.spy(), - exists: exists, - hget: hget, - hmset: hmset, - expire: expire, - del: del - }; -}; - -const fsStub = {}; -fsStub.statSync = sinon.stub(); -fsStub.createReadStream = sinon.stub(); -fsStub.createWriteStream = sinon.stub(); -fsStub.unlinkSync = sinon.stub(); - -const logStub = {}; -logStub.info = sinon.stub(); -logStub.error = sinon.stub(); - -const storage = proxyquire('../../server/storage', { - redis: redisStub, - 'redis-mock': redisStub, - fs: fsStub, - './log': function() { - return logStub; - } -}); - -describe('Testing Exists from local filesystem', function() { - it('Exists returns true when file exists', function() { - exists.callsArgWith(1, null, 1); - return storage - .exists('test') - .then(() => assert(1)) - .catch(err => assert.fail()); - }); - - it('Exists returns false when file does not exist', function() { - exists.callsArgWith(1, null, 0); - return storage - .exists('test') - .then(() => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Length from local filesystem', function() { - it('Filesize returns properly if id exists', function() { - fsStub.statSync.returns({ size: 10 }); - return storage - .length('Filename.moz') - .then(_reply => assert(1)) - .catch(err => assert.fail()); - }); - - it('Filesize fails if the id does not exist', function() { - fsStub.statSync.returns(null); - return storage - .length('Filename.moz') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Get from local filesystem', function() { - it('Get returns properly if id exists', function() { - fsStub.createReadStream.returns(1); - if (storage.get('Filename.moz')) { - assert(1); - } else { - assert.fail(); - } - }); - - it('Get fails if the id does not exist', function() { - fsStub.createReadStream.returns(null); - if (storage.get('Filename.moz')) { - assert.fail(); - } else { - assert(1); - } - }); -}); - -describe('Testing Set to local filesystem', function() { - it('Successfully writes the file to the local filesystem', function() { - const stub = sinon.stub(); - stub.withArgs('finish', sinon.match.any).callsArgWithAsync(1); - stub.withArgs('error', sinon.match.any).returns(1); - fsStub.createWriteStream.returns({ on: stub }); - - return storage - .set('test', { pipe: sinon.stub(), on: sinon.stub() }, 'Filename.moz', {}) - .then(() => { - assert(1); - }) - .catch(err => assert.fail()); - }); - - it('Fails when the file is not properly written to the local filesystem', function() { - const stub = sinon.stub(); - stub.withArgs('error', sinon.match.any).callsArgWithAsync(1); - stub.withArgs('close', sinon.match.any).returns(1); - fsStub.createWriteStream.returns({ on: stub }); - - return storage - .set('test', { pipe: sinon.stub() }, 'Filename.moz', 'moz.la') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Delete from local filesystem', function() { - it('Deletes properly if id exists', function() { - hget.callsArgWith(2, null, '123'); - fsStub.unlinkSync.returns(1); - return storage - .delete('Filename.moz', '123') - .then(reply => assert(reply)) - .catch(err => assert.fail()); - }); - - it('Delete fails if id does not exist', function() { - hget.callsArgWith(2, null, null); - return storage - .delete('Filename.moz', '123') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); - - it('Delete fails if the delete token does not match', function() { - hget.callsArgWith(2, null, null); - return storage - .delete('Filename.moz', '123') - .then(_reply => assert.fail()) - .catch(err => assert(1)); - }); -}); - -describe('Testing Forced Delete from local filesystem', function() { - it('Deletes properly if id exists', function() { - fsStub.unlinkSync.returns(1); - return storage.forceDelete('Filename.moz').then(reply => assert(reply)); - }); - - it('Deletes fails if id does not exist, but no reject is called', function() { - fsStub.unlinkSync.returns(0); - return storage.forceDelete('Filename.moz').then(reply => assert(!reply)); - }); -}); diff --git a/test/unit/metadata-tests.js b/test/unit/metadata-tests.js new file mode 100644 index 00000000..fbaeaefd --- /dev/null +++ b/test/unit/metadata-tests.js @@ -0,0 +1,65 @@ +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + ttl: sinon.stub(), + length: sinon.stub() +}; + +function request(id, meta) { + return { + params: { id }, + meta + }; +} + +function response() { + return { + sendStatus: sinon.stub(), + send: sinon.stub() + }; +} + +const metadataRoute = proxyquire('../../server/routes/metadata', { + '../storage': storage +}); + +describe('/api/metadata', function() { + afterEach(function() { + storage.ttl.reset(); + storage.length.reset(); + }); + + it('calls storage.[ttl|length] with the id parameter', async function() { + const req = request('x'); + const res = response(); + await metadataRoute(req, res); + sinon.assert.calledWith(storage.ttl, 'x'); + sinon.assert.calledWith(storage.length, 'x'); + }); + + it('sends a 404 on failure', async function() { + storage.length.returns(Promise.reject(new Error())); + const res = response(); + await metadataRoute(request('x'), res); + sinon.assert.calledWith(res.sendStatus, 404); + }); + + it('returns a json object', async function() { + storage.ttl.returns(Promise.resolve(123)); + storage.length.returns(Promise.resolve(987)); + const meta = { + dlimit: 1, + dl: 0, + metadata: 'foo' + }; + const res = response(); + await metadataRoute(request('x', meta), res); + sinon.assert.calledWithMatch(res.send, { + metadata: 'foo', + finalDownload: true, + size: 987, + ttl: 123 + }); + }); +}); diff --git a/test/unit/owner-tests.js b/test/unit/owner-tests.js new file mode 100644 index 00000000..a5dd6589 --- /dev/null +++ b/test/unit/owner-tests.js @@ -0,0 +1,81 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + metadata: sinon.stub() +}; + +function request(id, owner_token) { + return { + params: { id }, + body: { owner_token } + }; +} + +function response() { + return { + sendStatus: sinon.stub() + }; +} + +const ownerMiddleware = proxyquire('../../server/middleware/owner', { + '../storage': storage +}); + +describe('Owner Middleware', function() { + afterEach(function() { + storage.metadata.reset(); + }); + + it('sends a 404 when the id is not found', async function() { + const next = sinon.stub(); + storage.metadata.returns(Promise.resolve(null)); + const res = response(); + await ownerMiddleware(request('x', 'y'), res); + sinon.assert.notCalled(next); + sinon.assert.calledWith(res.sendStatus, 404); + }); + + it('sends a 401 when the owner_token is missing', async function() { + const next = sinon.stub(); + const meta = { owner: 'y' }; + storage.metadata.returns(Promise.resolve(meta)); + const res = response(); + await ownerMiddleware(request('x', null), res); + sinon.assert.notCalled(next); + sinon.assert.calledWith(res.sendStatus, 401); + }); + + it('sends a 401 when the owner_token does not match', async function() { + const next = sinon.stub(); + const meta = { owner: 'y' }; + storage.metadata.returns(Promise.resolve(meta)); + const res = response(); + await ownerMiddleware(request('x', 'z'), res); + sinon.assert.notCalled(next); + sinon.assert.calledWith(res.sendStatus, 401); + }); + + it('sends a 401 if the metadata call fails', async function() { + const next = sinon.stub(); + storage.metadata.returns(Promise.reject(new Error())); + const res = response(); + await ownerMiddleware(request('x', 'y'), res); + sinon.assert.notCalled(next); + sinon.assert.calledWith(res.sendStatus, 401); + }); + + it('sets req.meta and req.authorized on successful auth', async function() { + const next = sinon.stub(); + const meta = { owner: 'y' }; + storage.metadata.returns(Promise.resolve(meta)); + const req = request('x', 'y'); + const res = response(); + await ownerMiddleware(req, res, next); + assert.equal(req.meta, meta); + assert.equal(req.authorized, true); + sinon.assert.notCalled(res.sendStatus); + sinon.assert.calledOnce(next); + }); +}); diff --git a/test/unit/params-tests.js b/test/unit/params-tests.js new file mode 100644 index 00000000..cba749f9 --- /dev/null +++ b/test/unit/params-tests.js @@ -0,0 +1,56 @@ +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + setField: sinon.stub() +}; + +function request(id) { + return { + params: { id }, + body: {} + }; +} + +function response() { + return { + sendStatus: sinon.stub() + }; +} + +const paramsRoute = proxyquire('../../server/routes/params', { + '../storage': storage +}); + +describe('/api/params', function() { + afterEach(function() { + storage.setField.reset(); + }); + + it('calls storage.setField with the correct parameter', function() { + const req = request('x'); + const dlimit = 2; + req.body.dlimit = dlimit; + const res = response(); + paramsRoute(req, res); + sinon.assert.calledWith(storage.setField, 'x', 'dlimit', dlimit); + sinon.assert.calledWith(res.sendStatus, 200); + }); + + it('sends a 400 if dlimit is too large', function() { + const req = request('x'); + const res = response(); + req.body.dlimit = 21; + paramsRoute(req, res); + sinon.assert.calledWith(res.sendStatus, 400); + }); + + it('sends a 404 on failure', function() { + storage.setField.throws(new Error()); + const req = request('x'); + const res = response(); + req.body.dlimit = 2; + paramsRoute(req, res); + sinon.assert.calledWith(res.sendStatus, 404); + }); +}); diff --git a/test/unit/password-tests.js b/test/unit/password-tests.js new file mode 100644 index 00000000..03c76c4c --- /dev/null +++ b/test/unit/password-tests.js @@ -0,0 +1,53 @@ +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +const storage = { + setField: sinon.stub() +}; + +function request(id, body) { + return { + params: { id }, + body + }; +} + +function response() { + return { + sendStatus: sinon.stub() + }; +} + +const passwordRoute = proxyquire('../../server/routes/password', { + '../storage': storage +}); + +describe('/api/password', function() { + afterEach(function() { + storage.setField.reset(); + }); + + it('calls storage.setField with the correct parameter', function() { + const req = request('x', { auth: 'z' }); + const res = response(); + passwordRoute(req, res); + sinon.assert.calledWith(storage.setField, 'x', 'auth', 'z'); + sinon.assert.calledWith(storage.setField, 'x', 'pwd', true); + sinon.assert.calledWith(res.sendStatus, 200); + }); + + it('sends a 400 if auth is missing', function() { + const req = request('x', {}); + const res = response(); + passwordRoute(req, res); + sinon.assert.calledWith(res.sendStatus, 400); + }); + + it('sends a 404 on failure', function() { + storage.setField.throws(new Error()); + const req = request('x', { auth: 'z' }); + const res = response(); + passwordRoute(req, res); + sinon.assert.calledWith(res.sendStatus, 404); + }); +}); diff --git a/test/unit/s3-tests.js b/test/unit/s3-tests.js new file mode 100644 index 00000000..68e55f36 --- /dev/null +++ b/test/unit/s3-tests.js @@ -0,0 +1,154 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire').noCallThru(); + +function resolvedPromise(val) { + return { + promise: () => Promise.resolve(val) + }; +} + +function rejectedPromise(err) { + return { + promise: () => Promise.reject(err) + }; +} + +const s3Stub = { + headObject: sinon.stub(), + getObject: sinon.stub(), + upload: sinon.stub(), + deleteObject: sinon.stub() +}; + +const awsStub = { + S3: function() { + return s3Stub; + } +}; + +const S3Storage = proxyquire('../../server/storage/s3', { + 'aws-sdk': awsStub +}); + +describe('S3Storage', function() { + it('uses config.s3_bucket', function() { + const s = new S3Storage({ s3_bucket: 'foo' }); + assert.equal(s.bucket, 'foo'); + }); + + describe('length', function() { + it('returns the ContentLength', async function() { + s3Stub.headObject = sinon + .stub() + .returns(resolvedPromise({ ContentLength: 123 })); + const s = new S3Storage({ s3_bucket: 'foo' }); + const len = await s.length('x'); + assert.equal(len, 123); + sinon.assert.calledWithMatch(s3Stub.headObject, { + Bucket: 'foo', + Key: 'x' + }); + }); + + it('throws when id not found', async function() { + const err = new Error(); + s3Stub.headObject = sinon.stub().returns(rejectedPromise(err)); + const s = new S3Storage({ s3_bucket: 'foo' }); + try { + await s.length('x'); + assert.fail(); + } catch (e) { + assert.equal(e, err); + } + }); + }); + + describe('getStream', function() { + it('returns a Stream object', function() { + const stream = {}; + s3Stub.getObject = sinon + .stub() + .returns({ createReadStream: () => stream }); + const s = new S3Storage({ s3_bucket: 'foo' }); + const result = s.getStream('x'); + assert.equal(result, stream); + sinon.assert.calledWithMatch(s3Stub.getObject, { + Bucket: 'foo', + Key: 'x' + }); + }); + }); + + describe('set', function() { + it('calls s3.upload', async function() { + const file = { on: sinon.stub() }; + s3Stub.upload = sinon.stub().returns(resolvedPromise()); + const s = new S3Storage({ s3_bucket: 'foo' }); + await s.set('x', file); + sinon.assert.calledWithMatch(s3Stub.upload, { + Bucket: 'foo', + Key: 'x', + Body: file + }); + }); + + it('aborts upload if limit is hit', async function() { + const file = { + on: (ev, fn) => fn() + }; + const abort = sinon.stub(); + const err = new Error(); + s3Stub.upload = sinon.stub().returns({ + promise: () => Promise.reject(err), + abort + }); + const s = new S3Storage({ s3_bucket: 'foo' }); + try { + await s.set('x', file); + assert.fail(); + } catch (e) { + assert.equal(e.message, 'limit'); + sinon.assert.calledOnce(abort); + } + }); + + it('throws when s3.upload fails', async function() { + const file = { + on: sinon.stub() + }; + const err = new Error(); + s3Stub.upload = sinon.stub().returns(rejectedPromise(err)); + const s = new S3Storage({ s3_bucket: 'foo' }); + try { + await s.set('x', file); + assert.fail(); + } catch (e) { + assert.equal(e, err); + } + }); + }); + + describe('del', function() { + it('calls s3.deleteObject', async function() { + s3Stub.deleteObject = sinon.stub().returns(resolvedPromise(true)); + const s = new S3Storage({ s3_bucket: 'foo' }); + const result = await s.del('x'); + assert.equal(result, true); + sinon.assert.calledWithMatch(s3Stub.deleteObject, { + Bucket: 'foo', + Key: 'x' + }); + }); + }); + + describe('ping', function() { + it('calls s3.headBucket', async function() { + s3Stub.headBucket = sinon.stub().returns(resolvedPromise(true)); + const s = new S3Storage({ s3_bucket: 'foo' }); + const result = await s.ping(); + assert.equal(result, true); + sinon.assert.calledWithMatch(s3Stub.headBucket, { Bucket: 'foo' }); + }); + }); +}); diff --git a/test/unit/storage-tests.js b/test/unit/storage-tests.js new file mode 100644 index 00000000..18137fb8 --- /dev/null +++ b/test/unit/storage-tests.js @@ -0,0 +1,118 @@ +const assert = require('assert'); +const proxyquire = require('proxyquire').noCallThru(); + +const stream = {}; +class MockStorage { + length() { + return Promise.resolve(12); + } + getStream() { + return stream; + } + set() { + return Promise.resolve(); + } + del() { + return Promise.resolve(); + } + ping() { + return Promise.resolve(); + } +} + +const expire_seconds = 10; +const storage = proxyquire('../../server/storage', { + '../config': { + expire_seconds, + s3_bucket: 'foo', + env: 'development', + redis_host: 'localhost' + }, + '../log': () => {}, + './s3': MockStorage +}); + +describe('Storage', function() { + describe('ttl', function() { + it('returns milliseconds remaining', async function() { + await storage.set('x', null, { foo: 'bar' }); + const ms = await storage.ttl('x'); + await storage.del('x'); + assert.equal(ms, expire_seconds * 1000); + }); + }); + + describe('length', function() { + it('returns the file size', async function() { + const len = await storage.length('x'); + assert.equal(len, 12); + }); + }); + + describe('get', function() { + it('returns a stream', function() { + const s = storage.get('x'); + assert.equal(s, stream); + }); + }); + + describe('set', function() { + it('sets expiration to config.expire_seconds', async function() { + await storage.set('x', null, { foo: 'bar' }); + const s = await storage.redis.ttlAsync('x'); + await storage.del('x'); + assert.equal(Math.ceil(s), expire_seconds); + }); + + it('sets metadata', async function() { + const m = { foo: 'bar' }; + await storage.set('x', null, m); + const meta = await storage.redis.hgetallAsync('x'); + await storage.del('x'); + assert.deepEqual(meta, m); + }); + + //it('throws when storage fails'); + }); + + describe('setField', function() { + it('works', async function() { + storage.setField('x', 'y', 'z'); + const z = await storage.redis.hgetAsync('x', 'y'); + assert.equal(z, 'z'); + await storage.del('x'); + }); + }); + + describe('del', function() { + it('works', async function() { + await storage.set('x', null, { foo: 'bar' }); + await storage.del('x'); + const meta = await storage.metadata('x'); + assert.equal(meta, null); + }); + }); + + describe('ping', function() { + it('works', async function() { + await storage.ping(); + }); + }); + + describe('metadata', function() { + it('returns all metadata fields', async function() { + const m = { + pwd: true, + dl: 1, + dlimit: 1, + auth: 'foo', + metadata: 'bar', + nonce: 'baz', + owner: 'bmo' + }; + await storage.set('x', null, m); + const meta = await storage.metadata('x'); + assert.deepEqual(meta, m); + }); + }); +});