From: Justin Wind Date: Thu, 15 Apr 2021 23:41:19 +0000 (-0700) Subject: initial commit X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=0022b9596090da3878df1986394e97b28a15d881;p=urlittler initial commit --- 0022b9596090da3878df1986394e97b28a15d881 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7602b33 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,82 @@ +{ + "env": { + "browser": false, + "es6": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:node/recommended", + "plugin:security/recommended", + "plugin:sonarjs/recommended" + ], + "parserOptions": { + "ecmaVersion": 2018 + }, + "plugins": [ + "node", + "security", + "sonarjs" + ], + "rules": { + "array-element-newline": [ + "error", + "consistent" + ], + "arrow-parens": [ + "error", + "always" + ], + "arrow-spacing": [ + "error", + { + "after": true, + "before": true + } + ], + "block-scoped-var": "error", + "block-spacing": "error", + "brace-style": "error", + "callback-return": "error", + "camelcase": "error", + "capitalized-comments": "warn", + "class-methods-use-this": "error", + "comma-dangle": [ + "error", + "always-multiline" + ], + "comma-spacing": [ + "error", + { + "after": true, + "before": false + } + ], + "comma-style": [ + "error", + "last" + ], + "sonarjs/cognitive-complexity": "warn", + "keyword-spacing": "error", + "linebreak-style": [ + "error", + "unix" + ], + "no-unused-vars": [ + "error", { + "varsIgnorePattern": "^_" + } + ], + "object-curly-spacing": [ + "error", + "always" + ], + "prefer-const": "error", + "quotes": [ + "error", + "single" + ], + "strict": "error", + "vars-on-top": "error" + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..349ed65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +.nyc_output +coverage +.vscode diff --git a/.nycrc.json b/.nycrc.json new file mode 100644 index 0000000..497d8af --- /dev/null +++ b/.nycrc.json @@ -0,0 +1,6 @@ +{ + "reporter": [ + "lcov", + "text" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3681c58 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# URLittler + +The world needed another link shortener. + +* GET / - presents a simple webform for short link creation +* POST / { url } - create new short link for url +* GET /:link - visit short link +* GET /:link/info - data about link +* DELETE /:link { token } - expires short link +* PUT /:link { token, [url] } - update short link url, or create with admin access +* GET /admin/report - data about all links +* GET /static/:asset - serve asset diff --git a/init-static.sql b/init-static.sql new file mode 100644 index 0000000..49b8e49 --- /dev/null +++ b/init-static.sql @@ -0,0 +1,5 @@ +INSERT INTO link (id, url) VALUES ('static', '/static/index.html'); +INSERT INTO link (id, url) VALUES ('favicon.ico', '/static/favicon.ico'); +INSERT INTO link (id, url) VALUES ('robots.txt', '/static/robots.txt'); + +-- INSERT INTO link (id, url) VALUES ('wp-login.php', ''); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6e09c20 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@squeep/urlittler", + "version": "1.0.0", + "description": "another link shortener", + "main": "server.js", + "scripts": { + "coverage": "nyc npm test", + "eslint": "eslint *.js src", + "nodemon": "nodemon", + "test": "mocha --recursive" + }, + "repository": { + "type": "git", + "url": "https://git.squeep.com/urlittler" + }, + "engines": { + "node": ">=12.9" + }, + "author": "Justin Wind ", + "license": "ISC", + "dependencies": { + "@squeep/api-dingus": "git+https://git.squeep.com/squeep-api-dingus/#v1.0.0", + "argon2": "^0.28.2", + "better-sqlite3": "^7.4.1", + "pg-promise": "10.10.2", + "pm2": "^5.1.0", + "uuid": "^8.3.2" + }, + "devDependencies": { + "eslint": "^7.30.0", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-security": "^1.4.0", + "eslint-plugin-sonarjs": "^0.9.1", + "mocha": "^9.0.2", + "nyc": "^15.1.0", + "sinon": "^11.1.1" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..404a7c5 --- /dev/null +++ b/server.js @@ -0,0 +1,39 @@ +/* eslint-disable capitalized-comments */ +'use strict'; + +const http = require('http'); + +const DB = require('./src/db'); +const Logger = require('./src/logger'); +const Service = require('./src/service'); +const { fileScope } = require('./src/common'); +const { version } = require('./package.json'); + +const _fileScope = fileScope(__filename)('main'); + +const PORT = process.env.PORT || 3001; +const ADDR = process.env.LISTEN_ADDR || '127.0.0.1'; + +const logger = new Logger({ + // ignoreBelowLevel: 'info', +}); +const db = new DB(logger, { + connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2Fhubl%2Fnodejs%2Fpostgres_dev-13/urlittler', + // connectionString: 'sqlite://', +}); +const service = new Service(logger, db, { + proxyPrefix: process.env.PROXY_PREFIX || '', // '/lil', + manager: { + selfBaseUrl: process.env.BASE_URL || '', + }, +}); + +http.createServer((req, res) => { + service.dispatch(req, res); +}).listen(PORT, ADDR, (err) => { + if (err) { + logger.error(_fileScope, 'error starting server:', err); + throw err; + } + logger.info(_fileScope, `server (version ${version}) started on ${ADDR}:${PORT}`); +}); diff --git a/src/authenticator.js b/src/authenticator.js new file mode 100644 index 0000000..f3d33ec --- /dev/null +++ b/src/authenticator.js @@ -0,0 +1,404 @@ +'use strict'; + +const argon2 = require('argon2'); +const crypto = require('crypto'); +const common = require('./common'); +const Enum = require('./enum'); +const { ResponseError } = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +/** + * Let us roll our own authentication dingus. + * For 'admin' level access, we allow this custom header scheme, or optionally Basic if a password is stored. + * We shall authenticate an id/secret from a header of this format: + * Signature: id:epoch:hmac(id + epoch + body) + * + * For 'normal' level access, we allow a token passed as a parameter or a Bearer token. + */ + +const defaultOptions = { + basicRealm: 'Authenticate', + customAuthName: 'SqueepSignature', + timestampGraceMs: 120 * 1000, // In ms +}; + +class Authenticator { + constructor(logger, db, options = {}) { + common.setOptions(this, defaultOptions, options); + this.logger = logger; + this.db = db; + } + + + /** + * For Reasons, get rid of iffy b64 characters. + * @param {string} s + */ + static b64Armor(s) { + return s && s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '.'); + } + + + /** + * Put them back, if we need to. + * @param {string} s + */ + static b64Unarmor(s) { + return s && s.replace(/-/g, '+').replace(/_/g, '/').replace(/\./g, '='); + } + + + /** + * + * @param {string} secret + * @param {string} id + * @param {string} epoch + * @param {string} body + */ + static signature(secret, id, epoch, body) { + const hmac = crypto.createHmac('sha384', secret); + hmac.update(new Buffer.from(id)); + hmac.update(new Buffer.from(epoch.toString())); + hmac.update(new Buffer.from(body)); + const digest = hmac.digest('base64'); + return Authenticator.b64Armor(digest); + } + + + /** + * + * @param {number} size + */ + static async generateToken(size = 36) { + const randomBuffer = await common.randomBytesAsync(size); + const token = randomBuffer.toString('base64'); + return Authenticator.b64Armor(token); + } + + + /** + * + * @param {http.ServerResponse} res + */ + requestSignature(res) { + res.setHeader(Enum.Header.WWWAuthenticate, `${this.customAuthName}`); + throw new ResponseError(Enum.ErrorResponse.Unauthorized); + } + + + /** + * Check our bespoke scheme. If valid, update context with auth details. + * @param {String} reqSig + * @param {Object} ctx + * @param {string} ctx.rawBody + */ + async isValidSignature(reqSig, ctx) { + const _scope = _fileScope('isValidSignature'); + + this.logger.debug(_scope, 'checking authentication', { reqSig }); + + const reqSigParts = (reqSig || '').split(':'); + + if (reqSigParts.length !== 3) { + this.logger.debug(_scope, 'unparsable', { reqSigParts }); + return false; + } + + // Get secret for id or fail. + const authenticationId = reqSigParts[0]; + let authData; + await this.db.context(async (dbCtx) => { + authData = await this.db.getAuthById(dbCtx, authenticationId); + }); + const secret = authData && authData.secret; + + if (!secret) { + this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx }) + return false; + } + + // Check timestamp + ctx.authenticationEpoch = parseInt(reqSigParts[1], 10); + const currentEpoch = Date.now() / 1000; + ctx.authenticationDrift = currentEpoch - ctx.authenticationEpoch; + if (Math.abs(ctx.authenticationDrift) > this.timestampGraceMs) { + this.logger.debug(_scope, 'failed, invalid timestamp', { ctx }) + return false; + } + + // Check signature + ctx.authenticationSignatureProvided = reqSigParts[2]; + ctx.authenticationSignatureComputed = Authenticator.signature(secret, authenticationId, ctx.authenticationEpoch.toString(), ctx.rawBody); + if (ctx.authenticationSignatureProvided !== ctx.authenticationSignatureComputed) { + this.logger.debug(_scope, 'failed, signature mismatch', { ctx }); + return false; + } + + ctx.authenticationId = authenticationId; + ctx.authenticationSecret = secret; // Retain this for computing response signature + + return true; // Success + } + + + /** + * Check for valid basic auth. + * @param {string} credentials + * @param {object} ctx + */ + async isValidBasic(credentials, ctx) { + const _scope = _fileScope('isValidBasic'); + this.logger.debug(_scope, 'called', { ctx }); + + const [ authenticationId, authenticationPass ] = common.splitFirst(credentials, ':', ''); + + let authData; + await this.db.context(async (dbCtx) => { + authData = await this.db.getAuthById(dbCtx, authenticationId); + }); + if (!authData) { + this.logger.debug(_scope, 'failed, invalid authenticationId', { ctx }); + return false; + } + + // Update pwhash + // authData.password = await argon2.hash(newPassword, { type: argon2.id }); + if (authData.password.startsWith('$argon2')) { + if (await argon2.verify(authdata.password, authenticationPass)) { + this.logger.debug(_scope, 'failed argon2 verify', { ctx }); + return false; + } else { + this.logger.debug(_scope, 'passed argon2 verify', { ctx }); + } + } else { + if (authData.password !== authenticationPass) { + this.logger.debug(_scope, 'failed, password mismatch', { ctx }); + return false; + } + } + + ctx.authenticationId = authenticationId; + + return true; // Success + } + + + /** + * Check if a presented Bearer token matches the presented link. + * @param {string} credentials + * @param {object} ctx + */ + async isValidBearer(credentials, ctx) { + const _scope = _fileScope('isValidBearer'); + this.logger.debug(_scope, 'called', { ctx }); + + const linkId = ctx.params.id; + return await this.isValidToken(linkId, credentials); + } + + + /** + * + * @param {string} linkId + * @param {string} token + */ + async isValidToken(linkId, token) { + const _scope = _fileScope('isValidToken'); + this.logger.debug(_scope, 'called', { linkId, token }); + + if (!token) { + this.logger.debug(_scope, 'no token, ignoring', { linkId, token }); + return false; + } + + if (!linkId) { + this.logger.debug(_scope, 'no id, ignoring token', { linkId, token }); + return false; + } + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.getLinkById(dbCtx, linkId); + }); + + if (!link) { + this.logger.debug(_scope, 'failed, invalid id', { linkId, token }); + return false; + } + + // eslint-disable-next-line security/detect-possible-timing-attacks + if (link.authToken !== token) { + this.logger.debug(_scope, 'failed, token mismatch', { linkId, token }); + return false; + } + + // Success + return true; + } + + + /** + * Determine which sort of Authorization header is available, and either parse it or request one. + * @param {string} authorizationHeader + * @param {object} ctx + */ + async isValidAuthorization(authorizationHeader, ctx) { + const _scope = _fileScope('isValidAuthorization'); + this.logger.debug(_scope, 'called', { authorizationHeader, ctx }); + + const [ authMethod, b64Credentials ] = common.splitFirst(authorizationHeader, ' ', '').map((x) => x.trim()); + const credentials = Buffer.from(b64Credentials, 'base64').toString('utf-8'); + // eslint-disable-next-line sonarjs/no-small-switch + switch (authMethod.toLowerCase()) { + case 'basic': + return await this.isValidBasic(credentials, ctx); + + case 'bearer': + return await this.isValidBearer(credentials, ctx); + + default: + return false; + } + } + + + /** + * + * @param {http.ServerResponse} res + */ + requestBasic(res) { + res.setHeader(Enum.Header.WWWAuthenticate, `Basic realm="${this.basicRealm}", charset="UTF-8"`); + throw new ResponseError(Enum.ErrorResponse.Unauthorized); + } + + + /** + * Require a request passes auth. + * Sets ctx.authenticationId on valid non-token authentication. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async required(req, res, ctx) { + const _scope = _fileScope('required'); + this.logger.debug(_scope, 'called', { ctx }); + + let authData; + + // Prefer custom + authData = req.getHeader(Enum.Header.Signature); + if (authData) { + const validSignature = await this.isValidSignature(authData, ctx); + if (validSignature) { + return true; // Success + } + // Signature was present, but failed. Request signature auth. + return this.requestSignature(res); + } + + // Accept standards + authData = req.getHeader(Enum.Header.Authorization); + if (authData) { + const validAuthorization = await this.isValidAuthorization(authData, ctx); + if (validAuthorization) { + return true; // Success + } + return this.requestBasic(res); + } + + const linkId = ctx.params && ctx.params.id; + // If there is an id parameter, check for a valid token query parameter + if (linkId) { + authData = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token); + if (authData) { + const validToken = await this.isValidToken(linkId, authData); + if (validToken) { + return true; // Success + } + } + } + + return this.requestBasic(res); + } + + + /** + * Check and set authentication if provided and valid, but do not require it. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async optional(req, res, ctx) { + const _scope = _fileScope('optional'); + this.logger.debug(_scope, 'called', { ctx }); + + let authData; + + // Allow and prefer a valid signature. + authData = req.getHeader(Enum.Header.Signature); + if (authData) { + const validSignature = await this.isValidSignature(authData, ctx); + if (validSignature) { + return true; // Success + } + } + + // Allow valid auth. + authData = req.getHeader(Enum.Header.Authorization); + if (authData) { + const validAuthorization = await this.isValidAuthorization(authData, ctx); + if (validAuthorization) { + return true; // Success + } + } + + // Allow a valid plain token. + const linkId = ctx.params && ctx.params.id; + if (linkId) { + const token = (ctx.queryParams && ctx.queryParams.token) || (ctx.parsedBody && ctx.parsedBody.token); + if (token) { + const validToken = await this.isValidToken(linkId, token); + if (validToken) { + return true; // Success + } + } + } + + return false; + } + + + /** + * If a request included signature auth, respond in kind. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + signResponse(req, res, ctx) { + const _scope = _fileScope('signResponse'); + + // Keep huge payloads out of logs... + const logCtx = Object.assign({}, ctx); + if (logCtx.responseBody) { + if (typeof logCtx.responseBody !== 'string') { + logCtx.responseBody = logCtx.responseBody.toString(); + } + if (logCtx.responseBody.length > 128) { + logCtx.responseBody = logCtx.responseBody.slice(0, 128) + `... (${logCtx.responseBody.length} bytes)`; + } + } + this.logger.debug(_scope, 'called', { logCtx }); + + if (ctx.authenticationSecret && ctx.authenticationId) { + const currentEpoch = Date.now() / 1000; + const signature = Authenticator.signature(ctx.authenticationSecret, ctx.authenticationId, currentEpoch, ctx.responseBody || ''); + const signatureHeader = [ctx.authenticationId, currentEpoch, signature].join(':'); + res.setHeader(Enum.Header.Signature, signatureHeader); + this.logger.debug(_scope, 'added signature', { logCtx, signatureHeader }); + } + } + +} + +module.exports = Authenticator; diff --git a/src/common.js b/src/common.js new file mode 100644 index 0000000..21324c3 --- /dev/null +++ b/src/common.js @@ -0,0 +1,12 @@ +'use strict'; + +const { common } = require('@squeep/api-dingus'); + +const { promisify } = require('util'); +const { randomBytes } = require('crypto'); +const randomBytesAsync = promisify(randomBytes); + +module.exports = { + ...common, + randomBytesAsync, +}; diff --git a/src/db/base.js b/src/db/base.js new file mode 100644 index 0000000..b109efd --- /dev/null +++ b/src/db/base.js @@ -0,0 +1,72 @@ +'use strict'; + +const common = require('../common'); +const DBErrors = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +class BaseDatabase { + constructor(logger) { + this.logger = logger; + } + + static _camelfy(snakeCase, delim = '_') { + if (!snakeCase || typeof snakeCase.split !== 'function') { + return undefined; + } + const words = snakeCase.split(delim); + return [ + words.shift(), + ...words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)), + ].join(''); + } + + _notImplemented(method, args) { + const _scope = _fileScope(method); + this.logger.error(_scope, 'abstract method called', Array.from(args)); + throw new DBErrors.NotImplemented(); + } + + async context(fn) { + this._notImplemented('context', { fn }); + } + + async transaction(dbCtx, fn) { + this._notImplemented('transaction', { dbCtx, fn }); + } + + async getAuthById(dbCtx, id) { + this._notImplemented('getAuthById', { dbCtx, id }); + } + + async insertLink(dbCtx, id, url, authToken) { + this._notImplemented('insertLink', { dbCtx, id, url, authToken }); + } + + async getLinkById(dbCtx, id) { + this._notImplemented('getLinkById', { dbCtx, id }); + } + + async getLinkByUrl(dbCtx, url) { + this._notImplemented('getLinkByUrl', { dbCtx, url }); + } + + async accessLink(dbCtx, id) { + this._notImplemented('accessLink', { dbCtx, id }); + } + + async expireLink(dbCtx, id, expires) { + this._notImplemented('expireLink', { dbCtx, id, expires }); + } + + async updateLink(dbCtx, id, url) { + this._notImplemented('updateLink', { dbCtx, id, url }); + } + + async getAllLinks(dbCtx) { + this._notImplemented('getAllLinks', { dbCtx }); + } + +} + +module.exports = BaseDatabase; diff --git a/src/db/errors.js b/src/db/errors.js new file mode 100644 index 0000000..f13baba --- /dev/null +++ b/src/db/errors.js @@ -0,0 +1,30 @@ +'use strict'; + + const { DatabaseError } = require('../errors'); + + class NotImplemented extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(NotImplemented); + } + } + + class UnexpectedResult extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(UnexpectedResult); + } + } + + class UnsupportedEngine extends DatabaseError { + constructor(...args) { + super(...args); + Error.captureStackTrace(UnsupportedEngine); + } + } + +module.exports = { + NotImplemented, + UnexpectedResult, + UnsupportedEngine, +}; \ No newline at end of file diff --git a/src/db/index.js b/src/db/index.js new file mode 100644 index 0000000..452e0cf --- /dev/null +++ b/src/db/index.js @@ -0,0 +1,44 @@ +'use strict'; + +const common = require('../common'); +const DBErrors = require('./errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', +}; + +class DatabaseFactory { + constructor(logger, options, ...rest) { + const _scope = _fileScope + ':constructor'; + options = Object.assign({}, defaultOptions, options); + const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase(); + // eslint-disable-next-line sonarjs/no-small-switch + switch (protocol) { + case DatabaseFactory.Engines.PostgreSQL: { + const Postgres = require('./postgres'); + return new Postgres(logger, options, ...rest); + } + + case DatabaseFactory.Engines.SQLite: { + const SQLite = require('./sqlite'); + return new SQLite(logger, options, ...rest); + } + + default: + logger.error(_scope, 'unsupported connectionString', { options }); + throw new DBErrors.UnsupportedEngine(protocol); + } + } + + static get Engines() { + return { + PostgreSQL: 'postgresql', + SQLite: 'sqlite', + }; + } + +} + +module.exports = DatabaseFactory; diff --git a/src/db/postgres/index.js b/src/db/postgres/index.js new file mode 100644 index 0000000..f848bea --- /dev/null +++ b/src/db/postgres/index.js @@ -0,0 +1,311 @@ +'use strict'; + +const pgpInitOptions = { + capSQL: true, +}; + +const path = require('path'); +const pgp = require('pg-promise')(pgpInitOptions); +const BaseDatabase = require('../base'); +const common = require('../../common'); +const DBErrors = require('../errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: undefined, + queryLogLevel: undefined, + listenerPingDelayMs: 2000, + listenerRetryDelayMs: 5000, + listenerRetryTimes: 10, + listenerChannel: 'cache_invalidation', + listenerCallback: () => {}, + listenerLostCallback: () => {}, +}; + +class PostgresDatabase extends BaseDatabase { + constructor(logger, options, _pgp = pgp) { + const _scope = _fileScope('constructor'); + + super(logger); + common.setOptions(this, defaultOptions, options); + + this.logger.debug(_scope, 'connecting', { connectionString: this.connectionString }); + + this.db = _pgp(this.connectionString); + + if (this.queryLogLevel) { + pgpInitOptions.query = (e) => { + this.logger[this.queryLogLevel](_fileScope('query'), e.query, { params: e.params }); + }; + } + + pgpInitOptions.error = (err, e) => { + this.logger[this.queryLogLevel](_fileScope('pgp'), '', { err, e }); + }; + + pgpInitOptions.receive = (data, result, e) => { + const exemplaryRow = data[0]; + for (const prop in exemplaryRow) { + const camel = BaseDatabase._camelfy(prop); + if (!(camel in exemplaryRow)) { + for (const d of data) { + // eslint-disable-next-line security/detect-object-injection + d[camel] = d[prop]; + // eslint-disable-next-line security/detect-object-injection + delete d[prop]; + } + } + } + + if (this.queryLogLevel) { + this.logger[this.queryLogLevel](_fileScope('result'), e.query, PostgresDatabase._resultLog(result)); + } + }; + this._initStatements(_pgp); + } + + static _resultLog(result) { + return { + command: result.commaand, + rowCount: result.rowCount, + duration: result.duration, + }; + } + + _initStatements(_pgp) { + const _scope = _fileScope('_initStatements'); + + const qfOptions = { + minify: true, + }; + this.statement = _pgp.utils.enumSql(path.join(__dirname, 'sql'), {}, (file) => { + const qf = new _pgp.QueryFile(file, qfOptions); + this.logger.debug(_scope, file, { file: qf.file, options: qf.options, query: qf.query }); + if (qf.error) { + this.logger.error(_scope, file, { error: qf.error }); + throw qf.error; + } + return qf; + }); + + this.logger.debug(_scope, 'complete', { statementCount: Object.keys(this.statement).length }); + } + + // WIP not used yet + async _initListener() { + await this._reconnectListener(0, 1); + this._sendListenerNotifications(); + } + _sendListenerNotifications() { + const _scope = _fileScope('_sendListenerNotifications'); + setTimeout(async () => { + if (this.listenerConnection) { + try { + await this.listenerConnection.none('NOTIFY $1~, $2', [this.listenerChannel, 'ping']); + } catch (e) { + this.logger.error(_scope, 'failed', e); + } finally { + this._sendListenerNotifications(); + } + } + }, this.listenerPingDelayMs); + } + async _onListenerConnectionLost(err, ev) { + const _scope = _fileScope('_onConnectionLost'); + const eventName = 'notification'; + this.listenerConnection = null; + this.logger.error(_scope, 'listener connection lost', { err, ev }); + ev.client.removeListener(eventName, this._onListenerNotificationBound); + await this.listenerLostCallback(); + await this._reconnectListener(this.listenerRetryDelayMs, this.listenerRetryTimes); + this.logger.debug(_scope, 'listener reconnected'); + } + async _onListenerNotification(data) { + return await this.listenerCallback(data.payload); + } + async _reconnectListener(delay, retriesRemaining) { + const _scope = _fileScope('_reconnectListener'); + const eventName = 'notification'; + if (this.listenerConnection) { + this.listenerConnection.done(); + this.listenerConnection = null; + } + if (this.listenerReconnectPending) { + clearTimeout(this.listenerReconnectPending); + delete this.listenerReconnectPending; + } + return new Promise((resolve, reject) => { + this.listenerReconnectPending = setTimeout(async () => { + try { + this.listenerConnection = await this.db.connect({ + direct: true, + onLost: this._onListenerConnectionLost.bind(this), + }); + if (!this._onListenerNotificationBound) { + this._onListenerNotificationBound = this._onListenerNotification.bind(this); + } + this.listenerConnection.client.on(eventName, this._onListenerNotificationBound); + await this.listenerConnection.none('LISTEN $1~', this.listenerChannel); + delete this.listenerReconnectPending; + this.logger.debug(_scope, 'listener connection established'); + resolve(); + } catch (e) { + if (retriesRemaining > 0) { + try { + await this._reconnectListener(delay, retriesRemaining - 1); + resolve(); + } catch (e2) { + reject(e2); + } + } else { + reject(e); + } + } + }, delay); + }); + } + + + // eslint-disable-next-line class-methods-use-this + _postgresInfo(result) { + return { + changes: result.rowCount, + lastInsertRowid: result.rows.length ? result.rows[0].id : undefined, + duration: result.duration, + }; + } + + async context(fn) { + return await this.db.task(async (t) => await fn(t)); + } + + + async transaction(dbCtx, fn) { + dbCtx = dbCtx || this.db; + return await dbCtx.txIf(async (t) => await fn(t)); + } + + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const auth = await dbCtx.oneOrNone(this.statement.authGetById, { id }); + this.logger.debug(_scope, 'get', { auth }); + return auth; + } + + + static _epochFix(epoch) { + switch (epoch) { + case Infinity: + return Number.MAX_SAFE_INTEGER; + + case -Infinity: + return 0; + + default: + return epoch; + } + } + + + static _linkToNative(link) { + return link && { + ...link, + created: PostgresDatabase._epochFix(link.created), + lastAccess: PostgresDatabase._epochFix(link.lastAccess), + expires: PostgresDatabase._epochFix(link.expires), + }; + } + + async insertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('insertLink'); + this.logger.debug(_scope, 'called', { id, url, authToken }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkUpsert, { id, url, authToken }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkGetById, { id }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkGetByUrl, { url }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + dbCtx = dbCtx || this.db; + + const link = await dbCtx.oneOrNone(this.statement.linkAccess, { id }); + this.logger.debug(_scope, 'get', { link }); + return PostgresDatabase._linkToNative(link); + } + + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id, expires }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkExpire, { expires, id }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async updateLink(dbCtx, id, url) { + const _scope = _fileScope('updateLink'); + this.logger.debug(_scope, 'called', { id, url }); + + dbCtx = dbCtx || this.db; + + const result = await dbCtx.result(this.statement.linkUpdate, { id, url }); + this.logger.debug(_scope, 'result', PostgresDatabase._resultLog(result) ); + return this._postgresInfo(result); + } + + + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + dbCtx = dbCtx || this.db; + + const links = await dbCtx.manyOrNone(this.statement.linkGetAll, { }); + this.logger.debug(_scope, 'get', { links }); + return links.map((l) => PostgresDatabase._linkToNative(l)); + } + + +} + +module.exports = PostgresDatabase; diff --git a/src/db/postgres/sql/auth-get-by-id.sql b/src/db/postgres/sql/auth-get-by-id.sql new file mode 100644 index 0000000..98f0434 --- /dev/null +++ b/src/db/postgres/sql/auth-get-by-id.sql @@ -0,0 +1 @@ +SELECT * FROM auth WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-access.sql b/src/db/postgres/sql/link-access.sql new file mode 100644 index 0000000..5f4c128 --- /dev/null +++ b/src/db/postgres/sql/link-access.sql @@ -0,0 +1,5 @@ +UPDATE link SET accesses = accesses + 1, last_access = now() WHERE id = $(id) +RETURNING *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires diff --git a/src/db/postgres/sql/link-expire.sql b/src/db/postgres/sql/link-expire.sql new file mode 100644 index 0000000..a799b8a --- /dev/null +++ b/src/db/postgres/sql/link-expire.sql @@ -0,0 +1 @@ +UPDATE link SET expires = to_timestamp($(expires)) WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-all.sql b/src/db/postgres/sql/link-get-all.sql new file mode 100644 index 0000000..40a935f --- /dev/null +++ b/src/db/postgres/sql/link-get-all.sql @@ -0,0 +1,6 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link +ORDER BY id \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-by-id.sql b/src/db/postgres/sql/link-get-by-id.sql new file mode 100644 index 0000000..8eeb27d --- /dev/null +++ b/src/db/postgres/sql/link-get-by-id.sql @@ -0,0 +1,5 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-get-by-url.sql b/src/db/postgres/sql/link-get-by-url.sql new file mode 100644 index 0000000..113837a --- /dev/null +++ b/src/db/postgres/sql/link-get-by-url.sql @@ -0,0 +1,5 @@ +SELECT *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires +FROM link WHERE url = $(url) \ No newline at end of file diff --git a/src/db/postgres/sql/link-update.sql b/src/db/postgres/sql/link-update.sql new file mode 100644 index 0000000..fb04ea4 --- /dev/null +++ b/src/db/postgres/sql/link-update.sql @@ -0,0 +1 @@ +UPDATE link SET url = $(url) WHERE id = $(id) \ No newline at end of file diff --git a/src/db/postgres/sql/link-upsert.sql b/src/db/postgres/sql/link-upsert.sql new file mode 100644 index 0000000..9a8e024 --- /dev/null +++ b/src/db/postgres/sql/link-upsert.sql @@ -0,0 +1,10 @@ +INSERT INTO link (id, url, auth_token) + VALUES + ($(id), $(url), $(authToken)) + ON CONFLICT (id) DO UPDATE + SET + url = $(url) +RETURNING *, + extract(epoch from created) AS created, + extract(epoch from last_access) AS last_access, + extract(epoch from expires) AS expires diff --git a/src/db/postgres/sql/schema/1.0.0/apply.sql b/src/db/postgres/sql/schema/1.0.0/apply.sql new file mode 100644 index 0000000..d4d7a84 --- /dev/null +++ b/src/db/postgres/sql/schema/1.0.0/apply.sql @@ -0,0 +1,46 @@ +BEGIN; + + -- core data + CREATE TABLE link ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + last_access TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT '-infinity'::timestamptz, + accesses INTEGER NOT NULL DEFAULT 0, + expires TIMESTAMP WITH TIME ZONE, + auth_token TEXT + ); + + -- send notices to invalidate cacheable link data + CREATE OR REPLACE FUNCTION cache_invalidation_link() + RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ + DECLARE + payload varchar; + BEGIN + IF NEW.url != OLD.url OR NEW.expires != OLD.expires + THEN + payload = 'link|' || CAST(NEW.id AS text); + PERFORM pg_notify('cache_invalidation', payload); + END IF; + RETURN NEW; + END; + $$ + ; + CREATE TRIGGER cache_invalidation_link + AFTER UPDATE + ON link + FOR EACH ROW + EXECUTE PROCEDURE cache_invalidation(); + + -- better auth TBD + CREATE TABLE auth ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + password TEXT + ); + + -- migration complete + INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0); +COMMIT; diff --git a/src/db/postgres/sql/schema/1.0.0/revert.sql b/src/db/postgres/sql/schema/1.0.0/revert.sql new file mode 100644 index 0000000..38ed34d --- /dev/null +++ b/src/db/postgres/sql/schema/1.0.0/revert.sql @@ -0,0 +1,7 @@ +BEGIN; + DROP TRIGGER cache_invalidation_link ON link; + DROP FUNCTION cache_invalidation_link(); + DROP TABLE auth; + DROP TABLE link; + DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 0; +COMMIT; diff --git a/src/db/postgres/sql/schema/init.sql b/src/db/postgres/sql/schema/init.sql new file mode 100644 index 0000000..8343f3d --- /dev/null +++ b/src/db/postgres/sql/schema/init.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; +CREATE TABLE IF NOT EXISTS _meta_schema_version ( + major BIGINT NOT NULL, + minor BIGINT NOT NULL, + patch BIGINT NOT NULL, + applied TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + PRIMARY KEY (major, minor, patch) +); diff --git a/src/db/sqlite/index.js b/src/db/sqlite/index.js new file mode 100644 index 0000000..5971142 --- /dev/null +++ b/src/db/sqlite/index.js @@ -0,0 +1,288 @@ +'use strict'; + +const SQLite = require('better-sqlite3'); +// XXX: const NodeCleanup = require('node-cleanup'); +const BaseDatabase = require('../base'); +const common = require('../../common'); +const DBErrors = require('../errors'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + connectionString: '', + optimizeAfterChanges: 1000, + queryLogLevel: 'debug', +}; + +const EPOCH_NOW = '(strftime(\'%s\', \'now\'))'; + +class SQLiteDatabase extends BaseDatabase { + constructor(logger, options) { + const _scope = _fileScope('constructor'); + + super(logger); + common.setOptions(this, defaultOptions, options); + + this.dbFilename = this.connectionString.slice('sqlite://'.length) || ':memory:'; + + this.logger.debug(_scope, 'connecting', { dbFilename: this.dbFilename }); + + this.db = new SQLite(this.dbFilename, { + verbose: (query) => this.queryLogLevel && this.logger[this.queryLogLevel](_fileScope('statement'), 'executing', { query }), + }); + + this.changesSinceLastOptimize = BigInt(0); + + /* + NodeCleanup(() => { + this._optimize(); + this._closeConnection(); + }); + */ + + this.db.pragma('foreign_keys = on'); + this.db.pragma('journal_mode = WAL'); + this.db.defaultSafeIntegers(true); + + this._initTables(); + this._initStatements(); + } + + _initTables() { + const _scope = _fileScope('_initTables'); + const tableExists = this.db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND name=:tableName').pluck(true); + + const tables = [ + { + name: 'auth', + statements: [ + `CREATE TABLE IF NOT EXISTS auth ( + id TEXT NOT NULL PRIMARY KEY, + secret TEXT NOT NULL, + password TEXT + )`, + `INSERT INTO auth (id, secret, password) + VALUES + ('foo', 'secret', 'quux')`, + ], + }, + { + name: 'link', + statements: [ + `CREATE TABLE IF NOT EXISTS link ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created INTEGER NOT NULL DEFAULT ${EPOCH_NOW}, + last_access INTEGER NOT NULL DEFAULT 0, + accesses INTEGER NOT NULL DEFAULT 0, + expires INTEGER, + auth_token TEXT + )`, + 'CREATE INDEX IF NOT EXISTS link_url_idx ON link(url)', + ], + }, + ]; + + tables.forEach((t) => { + const table = tableExists.get({ tableName: t.name }); + if (table === undefined) { + t.statements.forEach((s) => { + this.logger.info(_scope, 'creating table', { tableName: t.name }); + this.db.prepare(s).run(); + }); + } + }); + } + + _initStatements() { + this.statement = { + getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'), + getLinkById: this.db.prepare('SELECT * FROM link WHERE id = :id'), + getLinkByUrl: this.db.prepare('SELECT * FROM link WHERE url = :url'), + insertLink: this.db.prepare('INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken)'), + updateLink: this.db.prepare('UPDATE link SET url = :url WHERE id = :id'), + incrementLinkAccess: this.db.prepare(`UPDATE link SET accesses = accesses + 1, last_access = ${EPOCH_NOW} WHERE id = :id`), + expireLink: this.db.prepare('UPDATE link SET expires = :expires WHERE id = :id'), + linkGetAll: this.db.prepare('SELECT * FROM link'), + }; + } + + _optimize() { + const _scope = _fileScope('_optimize'); + this.logger.debug(_scope, 'called', {}); + const optimizations = this.db.prepare('SELECT * FROM pragma_optimize(0x03)').all(); + this.logger.debug(_scope, 'pragma preview', { optimizations }); + this.db.pragma('optimize'); + this.changesSinceLastOptimize = BigInt(0); + } + + _maybeOptimize() { + if (this.changesSinceLastOptimize >= this.optimizeAfterChanges) { + this._optimize(); + } + } + + _sqliteInfo(info) { + if (info.changes) { + this.changesSinceLastOptimize += BigInt(info.changes); + this._maybeOptimize(); + } + return { + changes: Number(info.changes), + lastInsertRowid: Number(info.lastInsertRowid), + }; + } + + static _deOphidiate(row) { + return row && Object.keys(row).reduce((snakelessRow, k) => Object.assign(snakelessRow, { + // eslint-disable-next-line security/detect-object-injection + [BaseDatabase._camelfy(k)]: row[k], + }), {}); + } + + + async context(fn) { + const dbCtx = this.db; + return await fn(dbCtx); + } + + async transaction(dbCtx, fn, ...rest) { + dbCtx = dbCtx || this.db; + return await dbCtx.transaction(async (...args) => await fn(...args))(dbCtx, ...rest); + } + + async getAuthById(dbCtx, id) { + const _scope = _fileScope('getAuthById'); + this.logger.debug(_scope, 'called', { id }); + + let auth; + auth = this.statement.getAuthById.get({ id }); + auth = SQLiteDatabase._deOphidiate(auth); + + this.logger.debug(_scope, 'get', { auth }); + return auth; + } + + async insertLink(dbCtx, id, url, authToken) { + const _scope = _fileScope('insertLink'); + this.logger.debug(_scope, 'called', { id, url }); + + let info; + try { + info = this.statement.insertLink.run({ id, url, authToken }); + } catch (e) { + switch (e.code) { + case 'SQLITE_CONSTRAINT_UNIQUE': + case 'SQLITE_CONSTRAINT_PRIMARYKEY': { + this.logger.debug(_scope, 'updating existing id', { id, url }); + info = this.statement.updateLink.run({ id, url }); + break; + } + + default: { + this.logger.error(_scope, 'failed to insert link', { error: e, id, url }); + throw e; + } + } + } + this.logger.debug(_scope, 'run', { info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to insert link', { id, url, info }); + throw new DBErrors.UnexpectedResult(); + } + + return this._sqliteInfo(info); + } + + static _linkToNative(link) { + return link && { + id: link.id, + url: link.url, + created: Number(link.created), + lastAccess: Number(link.lastAccess), + accesses: Number(link.accesses), + expires: ('expires' in link) ? Number(link.expires) : undefined, + authToken: link.authToken, + }; + } + + async getLinkById(dbCtx, id) { + const _scope = _fileScope('getLinkById'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async getLinkByUrl(dbCtx, url) { + const _scope = _fileScope('getLinkByUrl'); + this.logger.debug(_scope, 'called', { url }); + + let link = this.statement.getLinkByUrl.get({ url }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { link }); + return SQLiteDatabase._linkToNative(link); + } + + async accessLink(dbCtx, id) { + const _scope = _fileScope('accessLink'); + this.logger.debug(_scope, 'called', { id }); + + let link = this.statement.getLinkById.get({ id }); + link = SQLiteDatabase._deOphidiate(link); + + this.logger.debug(_scope, 'get', { id, link }); + + if (link) { + const info = this.statement.incrementLinkAccess.run({ id }); + this.logger.debug(_scope, 'increment', { id, info }); + if (info.changes != 1) { + this.logger.error(_scope, 'failed to increment link access', { id, link, info }); + throw new DBErrors.UnexpectedResult(); + } + } + return SQLiteDatabase._linkToNative(link); + } + + async expireLink(dbCtx, id, expires) { + const _scope = _fileScope('expireLink'); + this.logger.debug(_scope, 'called', { id }); + + const info = this.statement.expireLink.run({ id, expires }); + if (info.changes != 1) { + throw new DBErrors.UnexpectedResult(); + } + return this._sqliteInfo(info); + } + + async updateLink(dbCtx, id, url) { + const _scope = _fileScope('updateLink'); + this.logger.debug(_scope, 'called', { id, url }); + + const info = this.statement.updateLink.run({ id, url }); + if (info.changes != 1) { + throw new DBErrors.UnexpectedResult(); + } + return this._sqliteInfo(info); + } + + // eslint-disable-next-line no-unused-vars + async getAllLinks(dbCtx) { + const _scope = _fileScope('getAllLinks'); + this.logger.debug(_scope, 'called', { }); + + let links; + links = this.statement.linkGetAll.all({}); + links = links.map((l) => SQLiteDatabase._deOphidiate(l)); + this.logger.debug(_scope, 'get', { links }); + links = links.map((l) => SQLiteDatabase._linkToNative(l)); + return links; + } + +} + +module.exports = SQLiteDatabase; diff --git a/src/enum.js b/src/enum.js new file mode 100644 index 0000000..10248a2 --- /dev/null +++ b/src/enum.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('./common'); +const { Enum } = require('@squeep/api-dingus'); + +common.mergeEnum(Enum, { + ContentType: { + }, + + ErrorResponse: { + InvalidAuthentication: { + statusCode: 403, + errorMessage: 'Invalid Authentication', + }, + InvalidLinkId: { + statusCode: 404, + errorMessage: 'Not Found', + details: 'not a valid link id', + }, + InvalidURLParameter: { + statusCode: 400, + errorMessage: 'Bad Request', + details: 'invalid url parameter', + }, + }, + + Header: { + Authorization: 'Authorization', + LastSeen: 'Last-Seen', + Signature: 'Signature', + WWWAuthenticate: 'WWW-Authenticate', + }, +}); + +module.exports = Enum; \ No newline at end of file diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..fb7a212 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,31 @@ +'use strict'; + +const { Errors } = require('@squeep/api-dingus'); + +class DatabaseError extends Errors.DingusError { + constructor(...args) { + super(...args); + Error.captureStackTrace(DatabaseError); + } +} + +class ServeStaticFile extends Errors.DingusError { + constructor(file, ...args) { + super(...args); + this.file = file; + delete this.stack; + } +} + +class SlugGeneratorExhausted extends Errors.DingusError { + constructor(...args) { + super(...args); + } +} + +module.exports = { + ...Errors, + DatabaseError, + ServeStaticFile, + SlugGeneratorExhausted, +}; \ No newline at end of file diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..7c8e3cb --- /dev/null +++ b/src/logger.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Log as JSON to stdout/stderr. + */ + +const common = require('./common'); + +// This is uncomfortable, but is the simplest way to let logging work for BigInts. +// TODO: revisit with better solution +BigInt.prototype.toJSON = function() { + return this.toString(); +} + +// Also uncomfortable +Object.defineProperty(Error.prototype, 'toJSON', { + configurable: true, + value: function () { + const result = {}; + const dupKey = function (key) { + // eslint-disable-next-line security/detect-object-injection + result[key] = this[key]; + }; + Object.getOwnPropertyNames(this).forEach(dupKey, this); + return result; + }, +}); + +const defaultOptions = { + ignoreBelowLevel: 'debug', + backend: console, +}; + +class Logger { + constructor(options = {}) { + common.setOptions(this, defaultOptions, options); + + this.logLevels = [ + 'error', + 'warn', + 'info', + 'log', + 'debug', + ]; + + const ignoreLevelIdx = this.logLevels.indexOf(this.ignoreBelowLevel); + this.logLevels.forEach((level) => { + // eslint-disable-next-line security/detect-object-injection + this[level] = (this.logLevels.indexOf(level) > ignoreLevelIdx) ? + () => {} : + Logger.levelTemplateFn(this.backend, level); + }); + } + + static levelTemplateFn(backend, level) { + return function (...args) { + // eslint-disable-next-line security/detect-object-injection + backend[level](Logger.payload(level, ...args)); + }; + } + + static payload(level, scope, message, data, ...other) { + return JSON.stringify({ + timestamp: Date.now(), + level: level, + scope: scope || '[unknown]', + message: message || '', + data: data || {}, + ...(other.length && { other }), + }); + } +} + +module.exports = Logger; diff --git a/src/manager.js b/src/manager.js new file mode 100644 index 0000000..a5035cd --- /dev/null +++ b/src/manager.js @@ -0,0 +1,364 @@ +'use strict'; + +const Authenticator = require('./authenticator'); +const common = require('./common'); +const Enum = require('./enum'); +const { ResponseError, ServeStaticFile, SlugGeneratorExhausted } = require('./errors'); +const { makeSlugGenerator } = require('./slug'); +const Template = require('./template'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + pageTitle: require('../package.json').name, + selfBaseUrl: '', + staticDirectory: '/static/', +}; + +class Manager { + constructor(logger, db, options = {}) { + common.setOptions(this, defaultOptions, options); + this.logger = logger; + this.db = db; + this.makeSlugGenerator = makeSlugGenerator; + } + + + /** + * Render root content. Shared by GET and POST. + * @param {Object} ctx + * @param {string} ctx.authToken + * @param {string} ctx.createdLink + * @param {string} ctx.message + * @param {string} ctx.sourceLink + */ + rootContent(ctx) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify({ + link: ctx.createdLink, + src: ctx.sourceLink, + token: ctx.authToken, + }); + + case Enum.ContentType.TextHTML: + default: + return Template.rootHTML(ctx, this.pageTitle); + } + } + + + /** + * GET request for root. + * NB only this manager gets req, to access headers for cache response. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getRoot(req, res, ctx) { + const _scope = _fileScope('getRoot'); + this.logger.debug(_scope, 'called', { ctx }); + + const content = this.rootContent(ctx); + + /* Since this GET response will never change while server is running, + * set headers to make it cacheable. + */ + if (!this.startTime) { + const now = new Date(); + this.startTime = now.toGMTString(); + this.startTimeMs = now.getTime(); + this.getRootETag = common.generateETag(undefined, undefined, content); + } + res.setHeader(Enum.Header.LastModified, this.startTime); + res.setHeader(Enum.Header.ETag, this.getRootETag); + + if (common.isClientCached(req, this.startTimeMs, this.getRootETag)) { + this.logger.debug(_scope, 'client cached response', { ctx }); + res.statusCode = 304; + res.end(); + return; + } + this.logger.debug(_scope, 'response', { ctx }); + res.end(content); + } + + + /** + * The heart of the littler. + * TODO: other styles of generators + * @param {*} dbCtx + */ + async _getNewIdentifier(dbCtx, url) { + const idGen = this.makeSlugGenerator(url); + let id, done; + do { + ({ done, value: id } = await idGen.next()); + if (done || !id) { + throw new SlugGeneratorExhausted(); + } + } while (await this.db.getLinkById(dbCtx, id)); + return id; + } + + + /** + * Validates the ctx.sourceLink is acceptable to add. + * @param {object} ctx + * @param {string} ctx.sourceLink + * @param {string=} ctx.authenticationId + */ + _validateContextURL(ctx) { + const _scope = _fileScope('_validateContextURL'); + + // Admin is allowed to create locally served entries. + if (ctx.sourceLink && ctx.sourceLink.startsWith(this.staticDirectory) + && ctx.authenticationId) { + return; + } + // Otherwise, url must be complete. + try { + new URL(ctx.sourceLink); + } catch (e) { + this.logger.debug(_scope, 'invalid url', { error: e, ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidURLParameter); + } + } + + + /** + * Get request for link, redirect. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getById(res, ctx) { + const _scope = _fileScope('getById'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.accessLink(dbCtx, ctx.params.id); + }); + if (!link) { + this.logger.info(_scope, 'no such link', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + this.logger.debug(_scope, 'found link', { link, ctx }); + + if (link.expires && link.expires <= Date.now()) { + this.logger.info(_scope, 'link expired', { link, ctx }); + const expiresDate = new Date(link.expires * 1000).toGMTString(); + res.setHeader(Enum.Header.LastSeen, expiresDate); + throw new ResponseError(Enum.ErrorResponse.Gone); + } + + if (link.url.startsWith(this.staticDirectory)) { + /* This is only a little awkward: throw back up to service to let the + * dingus handle the static file serving. + */ + const fileName = link.url.slice(this.staticDirectory.length); + this.logger.info(_scope, 'static link', { link, ctx }); + throw new ServeStaticFile(fileName); + } + + this.logger.info(_scope, 'redirect link', { link, ctx }); + + res.statusCode = 307; // Temporary Redirect + res.setHeader('Location', link.url); + res.end('Redirecting to ' + link.url); + } + + + /** + * POST request for root, create new link. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async postRoot(res, ctx) { + const _scope = _fileScope('postRoot'); + this.logger.debug(_scope, 'called', { ctx }); + + let content; + ctx.sourceLink = ctx.parsedBody.url; + if (!ctx.sourceLink) { + throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter'); + } + this._validateContextURL(ctx); + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + const existingLink = await this.db.getLinkByUrl(txCtx, ctx.sourceLink); + if (existingLink) { + res.statusCode = 200; // OK + ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`; + if (existingLink.expires < Date.now()) { + await this.db.expireLink(txCtx, existingLink.id, null); + ctx.authToken = existingLink.authToken; + this.logger.info(_scope, 'un-expired link', { existingLink, ctx }); + } + } else { + res.statusCode = 201; // Created + const id = await this._getNewIdentifier(txCtx, ctx.sourceLink); + ctx.authToken = await Authenticator.generateToken(); + await this.db.insertLink(txCtx, id, ctx.sourceLink, ctx.authToken); + ctx.createdLink = `${this.selfBaseUrl}/${id}`; + this.logger.info(_scope, 'created new link', { id, ctx }); + } + content = this.rootContent(ctx); + return res.end(content); + }); + }); + } + + + /** + * Put request for link, create with id, or update url. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async putById(res, ctx) { + const _scope = _fileScope('putById'); + this.logger.debug(_scope, 'called', { ctx }); + + ctx.sourceLink = ctx.parsedBody.url; + if (!ctx.sourceLink) { + throw new ResponseError(Enum.ErrorResponse.BadRequest, 'missing required parameter'); + } + this._validateContextURL(ctx); + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + const existingLink = await this.db.getLinkById(txCtx, ctx.params.id); + if (!existingLink) { + // To create a specific link, need admin authentication. + if (!ctx.authenticationId) { + this.logger.debug(_scope, 'cannot put without valid credentials', { ctx }); + throw new ResponseError(Enum.ErrorResponse.Forbidden); + } + + res.statusCode = 201; // Created + ctx.authToken = await Authenticator.generateToken(); + this.db.insertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken); + ctx.createdLink = `${this.selfBaseUrl}/${ctx.params.id}`; + this.logger.info(_scope, 'created new link', { ctx }); + } else { + // Update link + await this.db.insertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token); + ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`; + this.logger.info(_scope, 'updated link', { ctx }); + } + }); + }); + + const content = this.rootContent(ctx); + return res.end(content); + } + + + /** + * Delete request for link, expire. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async deleteById(res, ctx) { + const _scope = _fileScope('deleteById'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + await this.db.transaction(dbCtx, async (txCtx) => { + link = await this.db.getLinkById(txCtx, ctx.params.id); + if (!link) { + this.logger.debug(_scope, 'no such id', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + if (link.expires && link.expires < Date.now()) { + res.statusCode = 304; // Not Modified + return; + } + const when = Date.now() / 1000; + await this.db.expireLink(txCtx, ctx.params.id, when); + this.logger.info(_scope, 'expired link', { ctx, when }); + res.statusCode = 204; // No Content + }); + }); + res.end(); + } + + + /** + * Render link information. + * @param {*} ctx + * @param {*} details + */ + infoContent(ctx, details) { + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify(details); + + case Enum.ContentType.TextHTML: + default: + return Template.infoHTML(ctx, details, this.pageTitle); + } + } + + + /** + * Get request for link information. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getByIdInfo(res, ctx) { + const _scope = _fileScope('getByIdInfo'); + this.logger.debug(_scope, 'called', { ctx }); + + let link; + await this.db.context(async (dbCtx) => { + link = await this.db.getLinkById(dbCtx, ctx.params.id); + if (!link) { + this.logger.debug(_scope, 'no such link', { ctx }); + throw new ResponseError(Enum.ErrorResponse.InvalidLinkId); + } + this.logger.info(_scope, 'link found', { ctx, link }); + }); + + res.end(this.infoContent(ctx, link)); + } + + + /** Render report content. + * @param {object} ctx + * @param {object[]} links + */ + reportContent(ctx, links) { + switch (ctx.responseType) { + case Enum.ContentType.ApplicationJson: + return JSON.stringify(links); + + case Enum.ContentType.TextHTML: + default: + return Template.reportHTML(ctx, links, this.pageTitle); + } + } + + + /** + * Get request for link information. + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async getAdminReport(res, ctx) { + const _scope = _fileScope('getAdminReport'); + this.logger.debug(_scope, 'called', { ctx }); + + let links; + await this.db.context(async (dbCtx) => { + links = await this.db.getAllLinks(dbCtx); + this.logger.info(_scope, 'found links', { ctx, links }); + }); + + res.end(this.reportContent(ctx, links)); + } + +} + +module.exports = Manager; \ No newline at end of file diff --git a/src/service.js b/src/service.js new file mode 100644 index 0000000..1b09d2d --- /dev/null +++ b/src/service.js @@ -0,0 +1,258 @@ +'use strict'; + +/** + * Here we extend the base API server to define our routes and any route-specific + * behavior (middlewares) before handing off to the manager. + */ + +const { Dingus } = require('@squeep/api-dingus'); +const path = require('path'); +const common = require('./common'); +const Enum = require('./enum'); +const { ServeStaticFile } = require('./errors'); +const Authenticator = require('./authenticator'); +const Manager = require('./manager'); + +const _fileScope = common.fileScope(__filename); + +const defaultOptions = { + ignoreTrailingSlash: true, + staticDirectory: path.join(__dirname, '..', 'static'), + createRequiresAuth: false, + proxyPrefix: '', +}; + +class Service extends Dingus { + constructor(logger, db, options = {}) { + super(logger, { ...defaultOptions, ...options }); + common.setOptions(this, defaultOptions, options); + + this.authenticator = new Authenticator(logger, db, options.authenticator); + this._postRootAuth = this.authenticator[this.createRequiresAuth ? 'required' : 'optional'].bind(this.authenticator); + + this.manager = new Manager(logger, db, options.manager); + + this.responseTypes.push( + // 'other/handled-type', + ); + + this.on('POST', '/', this.handlerPostRoot.bind(this)); + this.on(['GET', 'HEAD'], '/', this.handlerGetRoot.bind(this)); + this.on(['GET', 'HEAD'], '/:id', this.handlerGetId.bind(this)); + this.on('DELETE', '/:id', this.handlerDeleteId.bind(this)); + this.on('PUT', '/:id', this.handlerPutId.bind(this)); + this.on(['GET', 'HEAD'], '/:id/info', this.handlerGetIdInfo.bind(this)); + this.on(['GET', 'HEAD'], '/static/:file', this.handlerGetStatic.bind(this)); + this.on(['GET', 'HEAD'], '/admin/report', this.handlerGetAdminReport.bind(this)); + } + + + /** + * Return a content-type appropriate rendering of an errorResponse object. + * @param {string} contentType content type of response + * @param {ResponseError|Exception} err + */ + renderError(contentType, err) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (contentType) { + // Handle any additional content types here + + default: + return super.renderError(contentType, err); + } + } + + + /** + * Parse rawBody from ctx as contentType into parsedBody. + * @param {string} contentType + * @param {object} ctx + */ + parseBody(contentType, ctx) { + // eslint-disable-next-line sonarjs/no-small-switch + switch (contentType) { + // Handle any additional content types here + + default: + super.parseBody(contentType, ctx); + } + } + + + /** + * Called before every request handler. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async preHandler(req, res, ctx) { + super.preHandler(req, res, ctx); + Dingus.setEndBodyHandler(req, res, ctx, this._endHandler.bind(this)); + } + + /** + * Do anything needed before sending. + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + _endHandler(req, res, ctx) { + this.authenticator.signResponse(req, res, ctx); + } + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerPostRoot(req, res, ctx) { + const _scope = _fileScope('handlerPostRoot'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.ingestBody(req, res, ctx); + await this._postRootAuth(req, res, ctx); + + await this.manager.postRoot(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetRoot(req, res, ctx) { + const _scope = _fileScope('handlerGetRoot'); + const responseTypes = [ + Enum.ContentType.TextHTML, + ]; + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + // NB special case for this handler, pass in req so it can check headers + await this.manager.getRoot(req, res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetId(req, res, ctx) { + const _scope = _fileScope('handlerGetId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + try { + await this.manager.getById(res, ctx); + } catch (e) { + if (e instanceof ServeStaticFile + && e.file) { + return await this.serveFile(req, res, ctx, this.staticDirectory, e.file); + } + throw e; + } + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerDeleteId(req, res, ctx) { + const _scope = _fileScope('handlerDeleteId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.deleteById(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerPutId(req, res, ctx) { + const _scope = _fileScope('handlerPutId'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.ingestBody(req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.putById(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetIdInfo(req, res, ctx) { + const _scope = _fileScope('handlerGetIdInfo'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + await this.manager.getByIdInfo(res, ctx); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetStatic(req, res, ctx) { + const _scope = _fileScope('handlerGetStatic'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + // We set default response type to handle any errors, but will override for actual static content type. + this.setResponseType(this.responseTypes, req, res, ctx); + this.authenticator.optional(req, res, ctx); + + await this.serveFile(req, res, ctx, this.staticDirectory, ctx.params.file); + } + + + /** + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {object} ctx + */ + async handlerGetAdminReport(req, res, ctx) { + const _scope = _fileScope('handlerAdminReport'); + this.logger.debug(_scope, 'called', { req: common.requestLogData(req), ctx }); + + Dingus.setHeadHandler(req, res, ctx); + + this.setResponseType(this.responseTypes, req, res, ctx); + await this.authenticator.required(req, res, ctx); + + await this.manager.getAdminReport(res, ctx); + } + + +} + +module.exports = Service; diff --git a/src/slug.js b/src/slug.js new file mode 100644 index 0000000..ef489f5 --- /dev/null +++ b/src/slug.js @@ -0,0 +1,45 @@ +'use strict'; + +const common = require('./common'); +const { createHash } = require('crypto'); + +/** + * TODO: base62 would be slightly prettier + * TODO: filter potential horrors + * @param {string} seed + * @param {number} maxLength + */ +async function newSlug(seed, maxLength = 86) { + let slug; + if (maxLength > 86) { + maxLength = 86; + } + if (typeof seed !== 'undefined') { + const hash = createHash('sha512'); + hash.update(seed); + slug = hash.digest('base64'); + } else { + const slugRaw = await common.randomBytesAsync(Math.round(maxLength * 3 / 4)); + slug = slugRaw.toString('base64'); + } + return slug.slice(0, maxLength).replace('/', '-').replace('+', '.'); +} + + +async function* makeSlugGenerator(seed, initialLength = 5, maxLength = 22) { + let length = initialLength; + let slug = await newSlug(seed, maxLength); + while (true) { + yield slug.slice(0, length); + length++; + if (length > maxLength) { + length = initialLength; + slug = await newSlug(undefined, maxLength); // Hash not suitable, try randomness. + } + } +} + +module.exports = { + makeSlugGenerator, + newSlug, +}; \ No newline at end of file diff --git a/src/template/index.js b/src/template/index.js new file mode 100644 index 0000000..cefbe45 --- /dev/null +++ b/src/template/index.js @@ -0,0 +1,7 @@ +'use strict'; + +module.exports = { + infoHTML: require('./info-html'), + reportHTML: require('./report-html'), + rootHTML: require('./root-html'), +}; \ No newline at end of file diff --git a/src/template/info-html.js b/src/template/info-html.js new file mode 100644 index 0000000..38ad53f --- /dev/null +++ b/src/template/info-html.js @@ -0,0 +1,32 @@ +'use strict'; + +const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never'; + +module.exports = (ctx, details, pageTitle) => { + const created = toDateString(details.created); + const lastAccess = toDateString(details.lastAccess); + const expires = details.expires ? toDateString(details.expires) : ''; + + return ` + + + + ${pageTitle} - ${details.id} Details + + + +
+

Link Details

+
+
+ +
url: ${details.url}
+
created:
+
accesses: ${details.accesses}
+
last access:
` + + (!details.expires ? '' : ` +
expires:
`) + ` +
+ +`; +}; \ No newline at end of file diff --git a/src/template/report-html.js b/src/template/report-html.js new file mode 100644 index 0000000..7313daf --- /dev/null +++ b/src/template/report-html.js @@ -0,0 +1,44 @@ +'use strict'; + +const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never'; + +module.exports = (ctx, links, pageTitle) => { + return ` + + + + ${pageTitle} - Link Report + + + +
+

Link Report

+
+
+ + + + + + + + + + + + ` + + links.map((l) => ` + + + + + + + + `).join('\n') + ` + +
idurlaccessescreatedexpireslast access
${l.id}${l.url}${l.accesses}${toDateString(l.created)}${toDateString(l.expires)}${toDateString(l.lastAccess)}
+ + +`; +}; \ No newline at end of file diff --git a/src/template/root-html.js b/src/template/root-html.js new file mode 100644 index 0000000..65e34c7 --- /dev/null +++ b/src/template/root-html.js @@ -0,0 +1,58 @@ +'use strict'; + +module.exports = (ctx, pageTitle) => { + return ` + + + + + ${pageTitle} + + + + +
+

${pageTitle}

+
` + + (!ctx.createdLink ? '' : ` + `) + + (!ctx.message ? '' : ` +
+ ${ctx.message} +
`) + ` + + +`; +}; \ No newline at end of file diff --git a/static/copy-link.svg b/static/copy-link.svg new file mode 100644 index 0000000..5f961b5 --- /dev/null +++ b/static/copy-link.svg @@ -0,0 +1,7 @@ + + + + + Svg Vector Icons : http://www.onlinewebfonts.com/icon + + \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..a473d17 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..f1c14e1 --- /dev/null +++ b/static/index.html @@ -0,0 +1,16 @@ + + + + + Static Assets + + + +
+

Static Assets

+
+
+ welcome to my static +
+ + diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/static/theme.css b/static/theme.css new file mode 100644 index 0000000..a0de35d --- /dev/null +++ b/static/theme.css @@ -0,0 +1,73 @@ +* { + font-family: helvetica, verdana, sans-serif; +} +body { + margin: 0; +} +header { + background: linear-gradient(0deg, rgba(255,255,255,0) 0%, rgb(160, 82, 45) 100%); +} +h1 { + margin: 0; + padding: 2em 1em .5em 1em; +} +section { + padding: 1em; + border-top: 1px solid sienna; +} +label { + display: inline-block; + font-size: larger; +} +input[type=text] { + width: 94%; + border: 1px solid sienna; + border-radius: .2em; + padding: .4em .2em .2em .2em; + font-size: larger; +} +input[type=submit] { + border-radius: .2em; + font-size: larger; + margin: .5em; + padding: .2em; +} +.meta-link { + font-size: small; + color: #666; +} +.meta-link .auth-token { + font-family: Courier, "Courier New", "Lucida Typewriter", monospace; +} +.copy-button { + display: inline-block; + vertical-align: baseline; + margin: .5em; + padding: .2em; + border-radius: .5em; +} +.copy-button:before { + background: url("copy-link.svg") no-repeat scroll center center / 100% auto rgba(0, 0, 0, 0); + content: ""; + display: inline-block; + padding: 1em; + margin: 0 .5em 0 0; + position: relative; + vertical-align: middle; +} +table { + width: 100%; +} +tbody tr:nth-child(even) td, tbody tr:nth-child(even) th { + background-color: #fff; + border: #4e9a06; +} +tbody tr:nth-child(odd) td, tbody tr:nth-child(odd) th { + background-color: #e4ffc7; + border: #4e9a06; +} +thead tr th { + background-color: #fff; + border-bottom: 1px solid #4e9a06; + padding: .5ex 0 .5ex 0; +} \ No newline at end of file diff --git a/test/src/authenticator.js b/test/src/authenticator.js new file mode 100644 index 0000000..70ce764 --- /dev/null +++ b/test/src/authenticator.js @@ -0,0 +1,497 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +const Authenticator = require('../../src/authenticator'); +const { Errors: { ResponseError } } = require('@squeep/api-dingus'); + + +const noExpectedException = 'did not get expected exception'; + +describe('Authenticator', function () { + let authenticator, logger, db, options; + + beforeEach(function () { + logger = { debug: () => {} }; + // logger = console; + db = { + context: async (fn) => fn({}), + getAuthById: async () => {}, + getLinkById: async () => {}, + }; + authenticator = new Authenticator(logger, db, options); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('b64 armor', function () { + it('reciprocates', function () { + const src = '3qmOlsGY5I3qY4O/tqF7LuvS400BBPh/AMzbyoIlvyXPwMA1tg=='; + + const armored = Authenticator.b64Armor(src); + const unarmored = Authenticator.b64Unarmor(armored); + + assert.strictEqual(unarmored, src); + }); + }); // b64 armor + + describe('generateToken', function () { + it('generates a token', async function () { + const result = await Authenticator.generateToken(); + assert.strictEqual(result.length, 36 * 4 / 3); + }); + }); // generateToken + + describe('signature', function () { + it('generates a signature', async function () { + const secret = 'secret'; + const id = 'identifier'; + const epoch = '1602887798'; + const body = 'some data'; + const expected = 'P5IXYXu5a7aobQvRinPQlN_k1g8GHycRpx3JrK1O7YJlqmhv3WRP5M3ubObPdUWM'; + const result = await Authenticator.signature(secret, id, epoch, body); + assert.strictEqual(result, expected); + }); + }); // generateToken + + describe('requestSignature', function () { + let res; + + beforeEach(function () { + res = { + setHeader: sinon.stub(), + }; + }); + + it('requests custom auth', function () { + try { + authenticator.requestSignature(res); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + assert.strictEqual(e.statusCode, 401); + assert(res.setHeader.called); + } + }); + }); // requestSignature + + describe('isValidSignature', function () { + let reqSig, ctx; + + beforeEach(function () { + reqSig = undefined; + ctx = { + rawBody: 'all about werewolves', + }; + sinon.stub(authenticator.db, 'getAuthById').resolves({ secret: 'bluemoon' }); + }); + + it('passes valid signature', async function () { + reqSig = 'awoo:1604155860:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, 'awoo'); + }); + + it('fails invalid data', async function () { + reqSig = 'not a signature'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails missing data', async function () { + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid signature', async function () { + reqSig = 'awoo:1604155860:bad signature'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid timestamp', async function () { + reqSig = 'awoo:0:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + + it('fails invalid auth id', async function () { + reqSig = 'awoo:1604155860:Zz43TbCdVeqRAqhxTnVpTcC2aMdh3PLXNw_FuQ4KVhjsEFTEnpokwTHb-Qbt3ttz'; + authenticator.db.getAuthById.restore(); + sinon.stub(authenticator.db, 'getAuthById').resolves({}); + sinon.stub(Date, 'now').returns(1604155861 * 1000); + const result = await authenticator.isValidSignature(reqSig, ctx); + assert.strictEqual(result, false); + }); + }); // isValidSignature + + describe('isValidToken', function () { + let token, linkId; + + beforeEach(function () { + token = undefined; + linkId = 'identifier'; + sinon.stub(authenticator.db, 'getLinkById'); + }); + + it('accepts token', async function () { + token = 'this_is_a_token'; + authenticator.db.getLinkById.resolves({ authToken: token }); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, true); + }); + + it('rejects missing token', async function () { + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects wrong token', async function () { + token = 'this_is_a_token'; + authenticator.db.getLinkById.resolves({ authToken: 'some_other_token' }); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects missing id', async function () { + token = 'this_is_a_token'; + linkId = undefined; + authenticator.db.getLinkById.resolves(); + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + + it('rejects invalid id', async function () { + token = 'this_is_a_token'; + const result = await authenticator.isValidToken(linkId, token); + assert.strictEqual(result, false); + }); + }); // isValidToken + + describe('isValidBasic', function () { + let credentials, ctx; + + beforeEach(function () { + sinon.stub(authenticator, 'requestBasic'); + credentials = 'id:password'; + ctx = {}; + }); + + it('accepts credentials', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves({ password: 'password' }); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, true); + assert.strictEqual(ctx.authenticationId, 'id'); + }); + + it('rejects wrong password', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves({ password: 'wrong_password' }); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, false); + assert(!('authenticationId' in ctx)); + }); + + it('rejects missing id', async function () { + sinon.stub(authenticator.db, 'getAuthById').resolves(); + const result = await authenticator.isValidBasic(credentials, ctx); + assert.strictEqual(result, false); + assert(!('authenticationId' in ctx)); + }); + }); // isValidBasic + + describe('isValidBearer', function () { + let credentials, ctx; + + beforeEach(function () { + credentials = 'id:password'; + ctx = { + params: {}, + }; + }); + + it('accepts token', async function () { + ctx.params.id = 'identifier'; + credentials = 'token'; + sinon.stub(authenticator.db, 'getLinkById').resolves({ authToken: 'token' }); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, true); + }); + + it('rejects wrong token', async function () { + ctx.params.id = 'identifier'; + sinon.stub(authenticator.db, 'getLinkById').resolves({ authToken: 'wrong_token' }); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, false); + }); + + it('rejects missing id', async function () { + sinon.stub(authenticator.db, 'getLinkById').resolves(); + const result = await authenticator.isValidBearer(credentials, ctx); + assert.strictEqual(result, false); + }); + }); // isValidBearer + + describe('isValidAuthorization', function () { + let header, ctx; + + beforeEach(function () { + header = undefined; + sinon.stub(authenticator, 'isValidBasic'); + sinon.stub(authenticator, 'isValidBearer'); + sinon.stub(authenticator, 'requestBasic'); + }); + + it('dispatches basic', async function () { + header = 'Basic blahblahblah='; + await authenticator.isValidAuthorization(header, ctx); + assert(authenticator.isValidBasic.called); + }); + + it('dispatches bearer', async function () { + header = 'Bearer blahblahblah='; + await authenticator.isValidAuthorization(header, ctx); + assert(authenticator.isValidBearer.called); + }); + + it('handles fallback', async function () { + header = 'Digest blahblahblah='; + const result = await authenticator.isValidAuthorization(header, ctx); + assert.strictEqual(result, false); + }); + }); // isValidAuthorization + + describe('requestBasic', function () { + let res; + + beforeEach(function () { + res = { + setHeader: sinon.stub(), + }; + }); + + it('requests custom auth', function () { + try { + authenticator.requestBasic(res); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ResponseError); + assert.strictEqual(e.statusCode, 401); + assert(res.setHeader.called); + } + }); + }); // requestBasic + + describe('required', function () { + let req, res, ctx; + + beforeEach(function () { + req = { + getHeader: sinon.stub(), + }; + res = {}; + ctx = {}; + sinon.stub(authenticator, 'isValidToken').resolves(false); + sinon.stub(authenticator, 'isValidSignature').resolves(false); + sinon.stub(authenticator, 'isValidAuthorization').resolves(false); + sinon.stub(authenticator, 'requestBasic'); + sinon.stub(authenticator, 'requestSignature'); + }); + + it('validates signature auth', async function () { + req.getHeader.returns('signature'); + authenticator.isValidSignature.restore(); + sinon.stub(authenticator, 'isValidSignature').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('requests signature auth on signature failure', async function () { + req.getHeader.returns('signature'); + await authenticator.required(req, res, ctx); + assert(authenticator.requestSignature.called); + }); + + it('validates authorization auth', async function () { + req.getHeader.onCall(1).returns('signature'); + authenticator.isValidAuthorization.restore(); + sinon.stub(authenticator, 'isValidAuthorization').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('requests authorization on auth failure', async function () { + req.getHeader.onCall(1).returns('signature'); + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('validates a queryparams token', async function () { + ctx.params = { id: 'identifier' }; + ctx.queryParams = { token: 'token' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('validates a body token', async function () { + ctx.params = { id: 'identifier' }; + ctx.parsedBody = { token: 'token' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.required(req, res, ctx); + assert.strictEqual(result, true); + assert(!authenticator.requestBasic.called); + }); + + it('fails invalid token', async function () { + ctx.params = { id: 'identifier' }; + ctx.parsedBody = { token: 'token' }; + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('fails missing token', async function () { + ctx.params = { id: 'identifier' }; + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + it('requests basic when all else fails', async function () { + await authenticator.required(req, res, ctx); + assert(authenticator.requestBasic.called); + }); + + }); // required + + describe('optional', function () { + let req, res, ctx; + + beforeEach(function () { + req = { + getHeader: sinon.stub(), + }; + res = {}; + ctx = {}; + sinon.stub(authenticator, 'isValidToken').resolves(false); + sinon.stub(authenticator, 'isValidSignature').resolves(false); + sinon.stub(authenticator, 'isValidAuthorization').resolves(false); + }); + + it('rejects with no auth', async function () { + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates signature auth', async function () { + req.getHeader.onCall(0).returns('signature'); + authenticator.isValidSignature.restore(); + sinon.stub(authenticator, 'isValidSignature').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid signature auth', async function () { + req.getHeader.onCall(0).returns('signature'); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates auth', async function () { + req.getHeader.onCall(1).returns('basic auth'); + authenticator.isValidAuthorization.restore(); + sinon.stub(authenticator, 'isValidAuthorization').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid auth', async function () { + req.getHeader.onCall(1).returns('basic auth'); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('validates queryparam token', async function () { + ctx.queryParams = { token: 'token' }; + ctx.params = { id: 'identifier' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('validates body token', async function () { + ctx.parsedBody = { token: 'token' }; + ctx.params = { id: 'identifier' }; + authenticator.isValidToken.restore(); + sinon.stub(authenticator, 'isValidToken').resolves(true); + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, true); + }); + + it('rejects invalid token', async function () { + ctx.queryParams = { token: 'token' }; + ctx.params = { id: 'identifier' }; + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + it('rejects missing token', async function () { + ctx.params = { id: 'identifier' }; + const result = await authenticator.optional(req, res, ctx); + assert.strictEqual(result, false); + }); + + }); // optional + + describe('signResponse', function () { + let req, res, ctx; + beforeEach(function () { + req = {}; + res = { + setHeader: sinon.stub(), + }; + ctx = {}; + }); + it('does nothing without auth', function () { + authenticator.signResponse(req, res, ctx); + assert(!res.setHeader.called); + }); + it('signs a response', function () { + ctx.authenticationId = 'identified'; + ctx.authenticationSecret = 'secret'; + ctx.responseBody = 'awoo'; + authenticator.signResponse(req, res, ctx); + assert(res.setHeader.called); + }); + it('signs an empty response', function () { + ctx.authenticationId = 'identified'; + ctx.authenticationSecret = 'secret'; + authenticator.signResponse(req, res, ctx); + assert(res.setHeader.called); + }); + it('covers big response non-logging', function () { + ctx.responseBody = 'orgle'.repeat(1000); + authenticator.signResponse(req, res, ctx); + }); + it('covers buffer response', function () { + ctx.responseBody = Buffer.from('orgle'.repeat(1000)); + authenticator.signResponse(req, res, ctx); + }); + }); // signResponse + +}); // Authenticator diff --git a/test/src/common.js b/test/src/common.js new file mode 100644 index 0000000..25fc433 --- /dev/null +++ b/test/src/common.js @@ -0,0 +1,8 @@ +/* eslint-env mocha */ + +'use strict'; + +const sinon = require('sinon'); + +describe('common', function () { +}); \ No newline at end of file diff --git a/test/src/db/base.js b/test/src/db/base.js new file mode 100644 index 0000000..753f8c9 --- /dev/null +++ b/test/src/db/base.js @@ -0,0 +1,70 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); +const BaseDatabase = require('../../../src/db/base'); +const DBErrors = require('../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('BaseDatabase', function () { + let logger, db; + + beforeEach(function () { + logger = { error: () => {} }; + // logger = console; + db = new BaseDatabase(logger); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('_camelfy', function () { + it('empty arg', function () { + const result = BaseDatabase._camelfy(); + assert.strictEqual(result, undefined); + }); + it('no change', function () { + const str = 'camelCase'; + const result = BaseDatabase._camelfy(str); + assert.strictEqual(result, str); + }); + it('does expected', function () { + const str = 'snake_case_thing'; + const result = BaseDatabase._camelfy(str); + assert.strictEqual(result, 'snakeCaseThing'); + }); + }); // _camelfy + + describe('interface', function () { + it('covers methods', async function () { + const methods = [ + 'context', + 'transaction', + 'getAuthById', + 'insertLink', + 'getLinkById', + 'getLinkByUrl', + 'accessLink', + 'expireLink', + 'updateLink', + 'getAllLinks', + ]; + const invokedMethods = methods.map(async (m) => { + try { + // eslint-disable-next-line security/detect-object-injection + await db[m](); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof DBErrors.NotImplemented); + } + }); + await Promise.all(invokedMethods); + }); + }); // interface + +}); \ No newline at end of file diff --git a/test/src/db/index.js b/test/src/db/index.js new file mode 100644 index 0000000..b94ca02 --- /dev/null +++ b/test/src/db/index.js @@ -0,0 +1,58 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +// eslint-disable-next-line node/no-unpublished-require +const sinon = require('sinon'); +const DatabaseFactory = require('../../../src/db'); +const DBErrors = require('../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('DatabaseFactory', function () { + let db, logger, options; + + beforeEach(function () { + logger = { + debug: () => {}, + error: () => {}, + info: () => {}, + }; + options = {}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('fails on invalid engine', function () { + try { + new DatabaseFactory(logger, options); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof DBErrors.UnsupportedEngine); + } + }); + + it('creates sqlite engine', function () { + this.slow(500); + options.connectionString = 'sqlite://'; + db = new DatabaseFactory(logger, options); + assert.strictEqual(db.constructor.name, 'SQLiteDatabase'); + }); + + it('creates postgres engine', function () { + this.slow(500); + const stubPgp = sinon.stub(); + stubPgp.utils = { + enumSql: sinon.stub().returns({}), + }; + stubPgp.QueryFile = sinon.stub().returns({}); + options.connectionString = 'postgresql://'; + db = new DatabaseFactory(logger, options, stubPgp); + assert.strictEqual(db.constructor.name, 'PostgresDatabase'); + }); + +}); \ No newline at end of file diff --git a/test/src/db/postgres/index.js b/test/src/db/postgres/index.js new file mode 100644 index 0000000..35a8328 --- /dev/null +++ b/test/src/db/postgres/index.js @@ -0,0 +1,348 @@ +/* eslint-disable capitalized-comments */ +/* eslint-env mocha */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const noExpectedException = 'did not get expected exception'; + +describe('PostgresDatabase', function () { + const PostgresDatabase = require('../../../../src/db/postgres'); + let pgpStub, db, logger, options, dbCtx; + + beforeEach(function () { + + pgpStub = () => { + const stub = { + result: sinon.stub(), + all: sinon.stub(), + get: sinon.stub(), + run: sinon.stub(), + one: sinon.stub(), + manyOrNone: sinon.stub(), + oneOrNone: sinon.stub(), + query: sinon.stub(), + batch: sinon.stub(), + }; + stub.tx = async (fn) => await fn(stub); + stub.txIf = async (fn) => await fn(stub); + stub.task = async (fn) => await fn(stub); + return stub; + }; + pgpStub.utils = { + enumSql: () => ({}), + }; + pgpStub.QueryFile = class {}; + pgpStub.end = () => {}, + + logger = { + debug: sinon.stub(), + error: sinon.stub(), + }; + options = {}; + db = new PostgresDatabase(logger, options, pgpStub); + db.statement = {}; + dbCtx = undefined; + }); + + describe('context', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.context(fn); + assert(fn.called); + }); + }); // context + + describe('transaction', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.transaction(undefined, fn); + assert(fn.called); + }); + }); // transaction + + describe('getAuthById', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getAuthById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAuthById + + describe('_epochFix', function () { + it('clamps infinity', function () { + const epoch = Infinity; + const expected = Number.MAX_SAFE_INTEGER; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + it('clamps negative infinity', function () { + const epoch = -Infinity; + const expected = 0; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + it('returns number', function () { + const epoch = 123; + const expected = 123; + const result = PostgresDatabase._epochFix(epoch); + assert.strictEqual(result, expected); + }); + }); // _epochFix + + describe('_linkToNative', function () { + it('handles missing link', function () { + const link = undefined; + const expected = undefined; + const result = PostgresDatabase._linkToNative(link); + assert.deepStrictEqual(result, expected); + }); + it('converts epochs', function () { + const link = { + id: 'id', + url: 'url', + expires: null, + lastAccess: -Infinity, + created: 123, + }; + const expected = { + id: 'id', + url: 'url', + expires: null, + lastAccess: 0, + created: 123, + }; + const result = PostgresDatabase._linkToNative(link); + assert.deepStrictEqual(result, expected); + }); + }); // _linkToNative + + describe('insertLink', function () { + let id, url, authToken; + it('stubbed success', async function () { + const returns = { + rowCount: 0, + rows: [], + duration: 0, + }; + id = 'id'; + db.db.result.resolves(returns); + const expected = { + changes: 0, + duration: 0, + lastInsertRowid: undefined, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.result.rejects(expectedExeption); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // insertLink + + describe('getLinkById', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getLinkById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + url = 'url'; + db.db.oneOrNone.resolves(expected); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + url = 'url'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.getLinkByUrl(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + it('stubbed success', async function () { + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }; + id = 'id'; + db.db.oneOrNone.resolves(expected); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.db.oneOrNone.rejects(expectedExeption); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + it('stubbed success', async function () { + const returns = { + rowCount: 1, + rows: [ { id: 1 } ], + duration: 10, + }; + const expected = { + changes: 1, + duration: 10, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.db.result.resolves(returns); + const result = await db.expireLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.db.result.rejects(expectedExeption); + try { + await db.expireLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // expireLink + + describe('updateLink', function () { + let id, expires; + it('stubbed success', async function () { + const returns = { + rowCount: 1, + rows: [ { id: 1 } ], + duration: 10, + }; + const expected = { + changes: 1, + duration: 10, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.db.result.resolves(returns); + const result = await db.updateLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.db.result.rejects(expectedExeption); + try { + await db.updateLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // updateLink + + describe('getAllLinks', function () { + it('stubbed success', async function () { + const expected = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + lastAccess: 0, + }, + ]; + db.db.manyOrNone.resolves(expected); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + db.db.manyOrNone.rejects(expectedExeption); + try { + await db.getAllLinks(dbCtx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAllLinks + +}); \ No newline at end of file diff --git a/test/src/db/sqlite/index.js b/test/src/db/sqlite/index.js new file mode 100644 index 0000000..0a1b803 --- /dev/null +++ b/test/src/db/sqlite/index.js @@ -0,0 +1,474 @@ +/* eslint-disable capitalized-comments */ +/* eslint-env mocha */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require +const DBErrors = require('../../../../src/db/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('SQLiteDatabase', function () { + const SQLiteDatabase = require('../../../../src/db/sqlite'); + let db, logger, options, dbCtx; + + beforeEach(function () { + logger = { + debug: sinon.stub(), + info: sinon.stub(), + error: sinon.stub(), + }; + options = {}; + db = new SQLiteDatabase(logger, options); + dbCtx = undefined; + }); + + describe('context', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.context(fn); + assert(fn.called); + }); + }); // context + + describe('transaction', function () { + it('covers', async function () { + const fn = sinon.stub(); + await db.transaction(dbCtx, fn); + assert(fn.called); + }); + }); // transaction + + describe('getAuthById', function () { + let id; + beforeEach(function () { + sinon.stub(db.statement.getAuthById, 'get'); + }); + + it('stubbed success', async function () { + const expected = { + id: 'id', + secret: 'secret', + password: 'password', + }; + id = 'id'; + db.statement.getAuthById.get.returns(expected); + const result = await db.getAuthById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getAuthById.get.throws(expectedExeption); + try { + await db.getAuthById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAuthById + + describe('insertLink', function () { + let id, url, authToken; + beforeEach(function () { + sinon.stub(db.statement.insertLink, 'run'); + sinon.stub(db.statement.updateLink, 'run'); + }); + + it('stubbed insert success', async function () { + const info = { + changes: BigInt(1), + lastInsertRowid: BigInt(123), + }; + id = 'id'; + db.statement.insertLink.run.returns(info); + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed update success', async function () { + const info = { + changes: BigInt(1), + lastInsertRowid: BigInt(123), + }; + id = 'id'; + db.statement.insertLink.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' }); + db.statement.updateLink.run.returns(info); + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + const result = await db.insertLink(dbCtx, id, url, authToken); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.insertLink.run.throws(expectedExeption); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + it('stubbed unexpected failure', async function () { + const expectedException = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.insertLink.run.returns(returns); + try { + await db.insertLink(dbCtx, id, url, authToken); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedException); + } + }); + }); // insertLink + + describe('getLinkById', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.getLinkById, 'get'); + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + const result = await db.getLinkById(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getLinkById.get.throws(expectedExeption); + try { + await db.getLinkById(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkById + + describe('getLinkByUrl', function () { + let url; + + beforeEach(function () { + sinon.stub(db.statement.getLinkByUrl, 'get'); + }); + + it('stubbed success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 123, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 123, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + url = 'url'; + db.statement.getLinkByUrl.get.returns(returns); + const result = await db.getLinkByUrl(dbCtx, url); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + url = 'url'; + db.statement.getLinkByUrl.get.throws(expectedExeption); + try { + await db.getLinkByUrl(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getLinkByUrl + + describe('accessLink', function () { + let id; + + beforeEach(function () { + sinon.stub(db.statement.getLinkById, 'get'); + sinon.stub(db.statement.incrementLinkAccess, 'run'); + }); + + it('stubbed exists success', async function () { + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + const expected = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 1 }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed missing success', async function () { + const returns = undefined; + const expected = undefined; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 0 }); + const result = await db.accessLink(dbCtx, id); + assert.deepStrictEqual(result, expected); + }); + it('stubbed increment failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }; + id = 'id'; + db.statement.getLinkById.get.returns(returns); + db.statement.incrementLinkAccess.run.returns({ changes: 0 }); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + db.statement.getLinkById.get.throws(expectedExeption); + try { + await db.accessLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // accessLink + + describe('expireLink', function () { + let id, expires; + + beforeEach(function () { + sinon.stub(db.statement.expireLink, 'run'); + }); + + it('stubbed success', async function () { + const returns = { + changes: 1, + lastInsertRowid: 123, + }; + const expected = { + changes: 1, + lastInsertRowid: 123, + }; + id = 'id'; + expires = null; + db.statement.expireLink.run.returns(returns); + const result = await db.expireLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed change failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.expireLink.run.returns(returns); + try { + await db.expireLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.statement.expireLink.run.throws(expectedExeption); + try { + await db.expireLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // expireLink + + describe('updateLink', function () { + let id, expires; + + beforeEach(function () { + sinon.stub(db.statement.updateLink, 'run'); + }); + + it('stubbed success', async function () { + const returns = { + changes: 1, + lastInsertRowid: 1, + }; + const expected = { + changes: 1, + lastInsertRowid: 1, + }; + id = 'id'; + expires = null; + db.statement.updateLink.run.returns(returns); + const result = await db.updateLink(dbCtx, id, expires); + assert.deepStrictEqual(result, expected); + }); + it('stubbed change failure', async function () { + const expectedExeption = DBErrors.UnexpectedResult; + const returns = { + changes: 0, + lastInsertRowid: undefined, + }; + id = 'id'; + db.statement.updateLink.run.returns(returns); + try { + await db.updateLink(dbCtx, id); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof expectedExeption, noExpectedException); + } + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + id = 'id'; + expires = null; + db.statement.updateLink.run.throws(expectedExeption); + try { + await db.updateLink(dbCtx, id, expires); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // updateLink + + describe('getAllLinks', function () { + beforeEach(function () { + sinon.stub(db.statement.linkGetAll, 'all'); + }); + + it('stubbed success', async function () { + const returns = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + 'auth_token': 'abc', + 'last_access': 0, + accesses: 0, + }, + ]; + const expected = [ + { + id: 'id', + url: 'url', + created: 0, + expires: 0, + authToken: 'abc', + lastAccess: 0, + accesses: 0, + }, + ]; + db.statement.linkGetAll.all.returns(returns); + const result = await db.getAllLinks(dbCtx); + assert.deepStrictEqual(result, expected); + }); + it('stubbed failure', async function () { + const expectedExeption = new Error('blah'); + db.statement.linkGetAll.all.throws(expectedExeption); + try { + await db.getAllLinks(dbCtx); + assert.fail(noExpectedException); + } catch (e) { + assert.deepStrictEqual(e, expectedExeption, noExpectedException); + } + }); + }); // getAllLinks + + describe('_optimize', function () { + let cslo, oac; + beforeEach(function () { + cslo = db.changesSinceLastOptimize; + oac = db.optimizeAfterChanges; + sinon.stub(db.db, 'prepare').returns({ + all: sinon.stub(), + }); + sinon.stub(db.db, 'pragma'); + }); + afterEach(function () { + db.changesSinceLastOptimize = cslo; + db.optimizeAfterChanges = oac; + }); + it('covers', function () { + db._optimize(); + assert(db.db.pragma.called); + }); + it('_maybeOptimize', function () { + db.changesSinceLastOptimize = BigInt(1000); + db.optimizeAfterChanges = BigInt(10); + sinon.stub(db, '_optimize'); + db._maybeOptimize(); + assert(db._optimize.called); + }); + }); + +}); \ No newline at end of file diff --git a/test/src/manager.js b/test/src/manager.js new file mode 100644 index 0000000..ad1c11f --- /dev/null +++ b/test/src/manager.js @@ -0,0 +1,427 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); // eslint-disable-line node/no-unpublished-require + +const Manager = require('../../src/manager'); +const common = require('../../src/common'); +const Enum = require('../../src/enum'); +const { ServeStaticFile, SlugGeneratorExhausted } = require('../../src/errors'); + +const noExpectedException = 'did not get expected exception'; + +describe('Manager', function () { + let manager, options; + let res, ctx; + + beforeEach(function () { + const stubLogger = { + debug: sinon.stub(), + error: sinon.stub(), + info: sinon.stub(), + }; + const stubDb = { + context: async (fn) => await fn({}), + transaction: async (_dbCtx, fn) => await fn({}), + getLinkById: sinon.stub(), + accessLink: sinon.stub(), + getLinkByUrl: sinon.stub(), + expireLink: sinon.stub(), + insertLink: sinon.stub(), + getAllLinks: sinon.stub(), + }; + options = {}; + res = { + end: sinon.stub(), + setHeader: sinon.stub(), + }; + ctx = { + params: {}, + }; + manager = new Manager(stubLogger, stubDb, options); + + }); + afterEach(function () { + sinon.restore(); + }); + + it('instantiates', function () { + assert(manager); + }); + + it('defaults options', function () { + manager = new Manager({}, {}); + }); + + describe('rootContent', function () { + it('generates content for empty context', function () { + const result = manager.rootContent(ctx); + assert(result.length); + }); + it('generates json content', function () { + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.rootContent(ctx); + assert(result.length); + JSON.parse(result); + }); + it('generates html content', function () { + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.rootContent(ctx); + assert(result.length); + }); + it('includes context fields', function () { + ctx.createdLink = 'http://example.com/foo'; + ctx.authToken = 'token'; + ctx.message = 'message'; + ctx.sourceLink = 'http://source.example.com/'; + const result = manager.rootContent(ctx); + assert(result.length); + }); + }); // rootContent + + describe('getRoot', function () { + let req; + beforeEach(function () { + sinon.stub(common, 'isClientCached'); + req = {}; + }); + it('normal response', async function () { + common.isClientCached.returns(false); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('repeat response', async function () { + manager.startTime = (new Date()).toGMTString(); + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + }); + it('cached response', async function () { + common.isClientCached.returns(true); + await manager.getRoot(req, res, ctx); + assert(res.end.called); + assert.strictEqual(res.statusCode, 304); + }); + }); // getRoot + + describe('_getNewIdentifier', function () { + const url = 'http://example.com/bar'; + let dbCtx, nextStub; + beforeEach(function () { + dbCtx = {}; + nextStub = sinon.stub(); + manager.db.getLinkById.onCall(0).resolves({ id:'existing' }).onCall(1).resolves(); + sinon.stub(manager, 'makeSlugGenerator').callsFake(() => { + return { + next: nextStub, + }; + }); + }); + it('gets identifiers', async function () { + nextStub.resolves({ value: 'slug', done: false }); + const result = await manager._getNewIdentifier(dbCtx, url); + assert(result); + assert.strictEqual(nextStub.callCount, 2); + }); + it('handles empty slug', async function () { + nextStub.resolves({ value: '', done: false }); + try { + await manager._getNewIdentifier(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof SlugGeneratorExhausted, noExpectedException); + } + }); + it('handles end of generator', async function () { + nextStub.resolves({ value: 'blah', done: true }); + try { + await manager._getNewIdentifier(dbCtx, url); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof SlugGeneratorExhausted, noExpectedException); + } + }); + }); // _getNewIdentifier + + describe('_validateContextURL', function () { + it('allows admin to create local static link', function () { + ctx.sourceLink = `${manager.staticDirectory}file.txt`; + ctx.authenticationId = 'awoo'; + manager._validateContextURL(ctx); + }); + it('accepts valid url', function () { + ctx.sourceLink = 'http://example.com/file.txt'; + manager._validateContextURL(ctx); + }); + it('rejects missing url', function () { + try { + manager._validateContextURL(ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, Enum.ErrorResponse.InvalidURLParameter.errorMessage, noExpectedException); + } + }); + it('rejects invalid url', function () { + ctx.sourceLink = 'not a url'; + try { + manager._validateContextURL(ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, Enum.ErrorResponse.InvalidURLParameter.errorMessage, noExpectedException); + } + }); + }); // _validateContextURL + + describe('getById', function () { + let link; + beforeEach(function () { + link = undefined; + }); + it('handles missing link', async function () { + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('handles expired link', async function () { + link = { + expires: 1, + }; + manager.db.accessLink.resolves(link) + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Gone'); + } + }); + it('handles local static link', async function () { + const file = 'foop.txt'; + link = { + url: `${manager.staticDirectory}${file}`, + }; + manager.db.accessLink.resolves(link); + try { + await manager.getById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert(e instanceof ServeStaticFile); + assert.strictEqual(e.file, file); + } + }); + it('redirects a link', async function () { + link = { + url: 'http://example.com/awoo', + }; + manager.db.accessLink.resolves(link); + await manager.getById(res, ctx); + assert.strictEqual(res.statusCode, 307); + }); + }); // getById + + describe('postRoot', function () { + beforeEach(function () { + ctx.parsedBody = {}; + }); + it('requires url parameter', async function () { + try { + await manager.postRoot(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Bad Request'); + } + }); + it('creates a link', async function () { + ctx.parsedBody.url = 'http://example.com/insert'; + await manager.postRoot(res, ctx); + assert(manager.db.insertLink.called); + assert(res.end.called); + }); + it('returns existing link', async function () { + ctx.parsedBody.url = 'http://example.com/existing'; + const existingLink = { + id: 'blah', + sourceLink: ctx.parsedBody.url, + }; + manager.db.getLinkByUrl.resolves(existingLink); + await manager.postRoot(res, ctx); + assert(!manager.db.insertLink.called); + assert(!manager.db.expireLink.called); + assert(res.end.called); + }); + it('restores expired link', async function () { + ctx.parsedBody.url = 'http://example.com/expired'; + const existingLink = { + id: 'blah', + sourceLink: ctx.parsedBody.url, + expires: 1, + }; + manager.db.getLinkByUrl.resolves(existingLink); + await manager.postRoot(res, ctx); + assert(!manager.db.insertLink.called); + assert(manager.db.expireLink.called); + assert(res.end.called); + }); + }); // postRoot + + describe('putById', function () { + let url; + beforeEach(function () { + url = 'http://example.com/put'; + ctx.parsedBody = {}; + }); + it('requires url parameter', async function () { + try { + await manager.putById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Bad Request'); + } + }); + it('updates existing', async function () { + ctx.parsedBody.url = url; + const existingLink = { + id: 'blah', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.putById(res, ctx); + assert(manager.db.insertLink.called); + assert(res.end.called); + }); + it('does not create without admin', async function () { + ctx.parsedBody.url = url; + try { + await manager.putById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Forbidden'); + } + }); + it('allows admin creation', async function () { + ctx.parsedBody.url = url; + ctx.authenticationId = 'blah'; + await manager.putById(res, ctx); + assert(manager.db.insertLink.called); + assert.strictEqual(res.statusCode, 201); + assert(res.end.called); + }); + }); // putById + + describe('deleteById', function () { + it('handles missing id', async function () { + try { + await manager.deleteById(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('expires link', async function () { + const existingLink = { + id: 'awoo', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.deleteById(res, ctx); + assert(manager.db.expireLink.called); + assert.strictEqual(res.statusCode, 204); + }); + it('ignores expired link', async function () { + const existingLink = { + id: 'awoo', + expires: 123, + }; + manager.db.getLinkById.resolves(existingLink); + await manager.deleteById(res, ctx); + assert(!manager.db.expireLink.called); + assert.strictEqual(res.statusCode, 304); + }); + }); // deleteById + + describe('infoContent', function () { + let details; + beforeEach(function () { + details = { + created: 1604155860, + lastAccess: 1604155861, + accesses: 6, + }; + }); + it('generates info', function () { + details.expires = 1604155862; + const result = manager.infoContent(ctx, details); + assert(result); + }); + it('generates json info', function () { + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.infoContent(ctx, details); + JSON.parse(result); + }); + it('generates html info', function () { + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.infoContent(ctx, details); + assert(result); + }); + }); // infoContent + + describe('getByIdInfo', function () { + it('handles missing link', async function () { + try { + await manager.getByIdInfo(res, ctx); + assert.fail(noExpectedException); + } catch (e) { + assert.strictEqual(e.message, 'Not Found'); + } + }); + it('gets link', async function () { + const existingLink = { + id: 'blah', + }; + manager.db.getLinkById.resolves(existingLink); + await manager.getByIdInfo(res, ctx); + assert(res.end.called); + }); + }); // getByIdInfo + + describe('reportContent', function () { + let links; + it('generates report', function () { + links = [ + { + id: 'awoo', + created: 1604155860, + lastAccess: 1604155861, + url: 'http://example.com/awoo', + }, + ]; + const result = manager.reportContent(ctx, links); + assert(result); + }); + it('generates json report', function () { + links = []; + ctx.responseType = Enum.ContentType.ApplicationJson; + const result = manager.reportContent(ctx, links); + JSON.parse(result); + }); + it('generates html report', function () { + links = []; + ctx.responseType = Enum.ContentType.TextHTML; + const result = manager.reportContent(ctx, links); + assert(result); + }); + }); // reportContent + + describe('getAdminReport', function () { + it('does links', async function () { + const links = []; + manager.db.getAllLinks.resolves(links); + await manager.getAdminReport(res, ctx); + assert(res.end.called); + }); + }); // getAdminReport + +}); // Manager \ No newline at end of file diff --git a/test/src/service.js b/test/src/service.js new file mode 100644 index 0000000..4e1d017 --- /dev/null +++ b/test/src/service.js @@ -0,0 +1,185 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +// eslint-disable-next-line node/no-unpublished-require +const sinon = require('sinon'); + +const Service = require('../../src/service'); +const { ServeStaticFile } = require('../../src/errors'); + + +describe('service', function () { + let service, logger, options; + let req, res, ctx; + const dbStub = {}; + + beforeEach(function () { + options = {}; + logger = {}; + // logger = console; + service = new Service(logger, dbStub, options); + sinon.stub(service.manager); + sinon.stub(service.authenticator); + sinon.stub(service, 'setResponseType'); + sinon.stub(service, 'serveFile'); + sinon.stub(service, 'ingestBody').resolves(); + req = { + getHeader: sinon.stub(), + }; + res = { + setHeader: sinon.stub(), + write: sinon.stub(), + end: sinon.stub(), + }; + ctx = {}; + }); + + afterEach(function () { + sinon.restore(); + }); + + it('instantiates', function () { + assert(service); + }); + + it('covers no options', function () { + const r = new Service(logger, dbStub); + assert(r); + }); + + describe('renderError', function () { + it('covers default', function () { + const contentType = 'text/plain'; + const err = { + statusCode: 418, + errorMessage: 'I am a teapot', + details: 'what are you', + }; + const result = service.renderError(contentType, err); + assert.strictEqual(result, 'I am a teapot\r\nwhat are you'); + }); + }); // renderError + + describe('parseBody', function () { + it('covers default', function () { + const contentType = 'application/json'; + const someData = { foo: 'bar', quux: 3 }; + ctx.rawBody = JSON.stringify(someData); + service.parseBody(contentType, ctx); + assert.deepStrictEqual(ctx.parsedBody, someData); + }); + }); // parseBody + + describe('preHandler', function () { + it('covers default', async function () { + await service.preHandler(req, res, ctx); + }); + }); // preHandler + + describe('_endHandler', function () { + beforeEach(function () { + req = {}; + res = {}; + ctx = {}; + }); + it('covers', function() { + service._endHandler(req, res, ctx); + assert(service.authenticator.signResponse.called); + }); + }); // _endHandler + + describe('handlerPostRoot', function () { + it('covers public mode', async function () { + sinon.stub(service, '_postRootAuth'); + + await service.handlerPostRoot(req, res, ctx); + assert(service.manager.postRoot.called); + }); + it('covers private mode', async function () { + service = new Service(logger, dbStub, { createRequiresAuth: true }); + sinon.stub(service.manager); + sinon.stub(service.authenticator); + sinon.stub(service, '_postRootAuth'); + sinon.stub(service, 'setResponseType'); + sinon.stub(service, 'serveFile'); + sinon.stub(service, 'ingestBody').resolves(); + + await service.handlerPostRoot(req, res, ctx); + assert(service.manager.postRoot.called); + }); + }); // handlerPostRoot + + describe('handlerGetRoot', function () { + it('covers', async function () { + await service.handlerGetRoot(req, res, ctx); + assert(service.manager.getRoot.called); + }); + }); // handlerGetRoot + + describe('handlerGetId', function () { + it('covers', async function () { + await service.handlerGetId(req, res, ctx); + assert(service.manager.getById.called); + }); + it('covers static', async function () { + service.manager.getById.rejects(new ServeStaticFile('this_file.txt')); + await service.handlerGetId(req, res, ctx); + assert(service.manager.getById.called); + assert(service.serveFile.called); + }); + it('cover errors', async function () { + const expectedException = 'blah'; + service.manager.getById.rejects(expectedException); + try { + await service.handlerGetId(req, res, ctx); + assert.fail('did not get expected exception'); + } catch (e) { + assert.strictEqual(e.name, expectedException, 'did not get expected exception'); + } + assert(service.manager.getById.called); + }); + }); // handlerGetId + + describe('handlerDeleteId', function () { + it('covers', async function () { + await service.handlerDeleteId(req, res, ctx); + assert(service.manager.deleteById.called); + }); + }); // handlerDeleteId + + describe('handlerPutId', function () { + it('covers', async function () { + await service.handlerPutId(req, res, ctx); + assert(service.manager.putById.called); + }); + }); // handlerPutId + + describe('handlerGetIdInfo', function () { + it('covers', async function () { + await service.handlerGetIdInfo(req, res, ctx); + assert(service.manager.getByIdInfo.called); + }); + }); // handlerGetIdInfo + + describe('handlerGetStatic', function () { + it('covers', async function () { + ctx.params = { + file: '', + }; + await service.handlerGetStatic(req, res, ctx); + assert(service.serveFile.called); + }); + }); // handlerGetStatic + + describe('handlerGetAdminReport', function () { + it('covers', async function () { + await service.handlerGetAdminReport(req, res, ctx); + assert(service.authenticator.required.called); + assert(service.manager.getAdminReport.called); + }); + }); + +}); \ No newline at end of file diff --git a/test/src/slug.js b/test/src/slug.js new file mode 100644 index 0000000..4b768c7 --- /dev/null +++ b/test/src/slug.js @@ -0,0 +1,72 @@ +/* eslint-env mocha */ +/* eslint-disable capitalized-comments */ + +'use strict'; + +const assert = require('assert'); +const sinon = require('sinon'); + +describe('slug', function () { + const Slug = require('../../src/slug'); + + const maxSlugLength = 86; + + afterEach(function () { + sinon.restore(); + }); + + describe('newSlug', function () { + it('generates a slug from seed', async function () { + const seed = ''; + const seedSHA512b64trunc = 'z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg-SpIdNs6c5H0NE8XYXysP.DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg'; + const result = await Slug.newSlug(seed); + assert.strictEqual(result, seedSHA512b64trunc); + }); + it('generates a random slug', async function () { + const result = await Slug.newSlug(); + assert.strictEqual(result.length, maxSlugLength); + }); + it('only makes a slug so big', async function () { + const slugLen = 222; + const result = await Slug.newSlug(undefined, slugLen); + assert.strictEqual(result.length, maxSlugLength); + }); + }); // newSlug + + describe('makeSlugGenerator', function () { + it('makes a slug generator which generates a slug', async function () { + const seed = ''; + const sg = Slug.makeSlugGenerator(seed); + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value.length, 5); + }); + it('makes a slug generator which generates many slugs', async function () { + const seed = ''; + const sg = Slug.makeSlugGenerator(seed, 5, 8); + + const checkNext = async (expected) => { + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value, expected); + }; + const checkNextLen = async (expected) => { + const { done, value } = await sg.next(); + assert(!done); + assert.strictEqual(value.length, expected); + }; + + await checkNext('z4PhN'); + await checkNext('z4PhNX'); + await checkNext('z4PhNX7'); + await checkNext('z4PhNX7v'); + await checkNextLen(5); + await checkNextLen(6); + await checkNextLen(7); + await checkNextLen(8); + await checkNextLen(5); + await checkNextLen(6); + }); + }); // makeSlugGenerator + +}); // slug \ No newline at end of file