initial commit
authorJustin Wind <justin.wind+git@gmail.com>
Thu, 15 Apr 2021 23:41:19 +0000 (16:41 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Thu, 15 Jul 2021 22:32:08 +0000 (15:32 -0700)
49 files changed:
.eslintrc.json [new file with mode: 0644]
.gitignore [new file with mode: 0644]
.nycrc.json [new file with mode: 0644]
README.md [new file with mode: 0644]
init-static.sql [new file with mode: 0644]
package.json [new file with mode: 0644]
server.js [new file with mode: 0644]
src/authenticator.js [new file with mode: 0644]
src/common.js [new file with mode: 0644]
src/db/base.js [new file with mode: 0644]
src/db/errors.js [new file with mode: 0644]
src/db/index.js [new file with mode: 0644]
src/db/postgres/index.js [new file with mode: 0644]
src/db/postgres/sql/auth-get-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/link-access.sql [new file with mode: 0644]
src/db/postgres/sql/link-expire.sql [new file with mode: 0644]
src/db/postgres/sql/link-get-all.sql [new file with mode: 0644]
src/db/postgres/sql/link-get-by-id.sql [new file with mode: 0644]
src/db/postgres/sql/link-get-by-url.sql [new file with mode: 0644]
src/db/postgres/sql/link-update.sql [new file with mode: 0644]
src/db/postgres/sql/link-upsert.sql [new file with mode: 0644]
src/db/postgres/sql/schema/1.0.0/apply.sql [new file with mode: 0644]
src/db/postgres/sql/schema/1.0.0/revert.sql [new file with mode: 0644]
src/db/postgres/sql/schema/init.sql [new file with mode: 0644]
src/db/sqlite/index.js [new file with mode: 0644]
src/enum.js [new file with mode: 0644]
src/errors.js [new file with mode: 0644]
src/logger.js [new file with mode: 0644]
src/manager.js [new file with mode: 0644]
src/service.js [new file with mode: 0644]
src/slug.js [new file with mode: 0644]
src/template/index.js [new file with mode: 0644]
src/template/info-html.js [new file with mode: 0644]
src/template/report-html.js [new file with mode: 0644]
src/template/root-html.js [new file with mode: 0644]
static/copy-link.svg [new file with mode: 0644]
static/favicon.ico [new file with mode: 0644]
static/index.html [new file with mode: 0644]
static/robots.txt [new file with mode: 0644]
static/theme.css [new file with mode: 0644]
test/src/authenticator.js [new file with mode: 0644]
test/src/common.js [new file with mode: 0644]
test/src/db/base.js [new file with mode: 0644]
test/src/db/index.js [new file with mode: 0644]
test/src/db/postgres/index.js [new file with mode: 0644]
test/src/db/sqlite/index.js [new file with mode: 0644]
test/src/manager.js [new file with mode: 0644]
test/src/service.js [new file with mode: 0644]
test/src/slug.js [new file with mode: 0644]

diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644 (file)
index 0000000..7602b33
--- /dev/null
@@ -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 (file)
index 0000000..349ed65
--- /dev/null
@@ -0,0 +1,4 @@
+node_modules
+.nyc_output
+coverage
+.vscode
diff --git a/.nycrc.json b/.nycrc.json
new file mode 100644 (file)
index 0000000..497d8af
--- /dev/null
@@ -0,0 +1,6 @@
+{
+  "reporter": [
+    "lcov",
+    "text"
+  ]
+}
diff --git a/README.md b/README.md
new file mode 100644 (file)
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 (file)
index 0000000..49b8e49
--- /dev/null
@@ -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 (file)
index 0000000..6e09c20
--- /dev/null
@@ -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 <jwind-urlittler@squeep.com>",
+  "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 (file)
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 (file)
index 0000000..f3d33ec
--- /dev/null
@@ -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 (file)
index 0000000..21324c3
--- /dev/null
@@ -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 (file)
index 0000000..b109efd
--- /dev/null
@@ -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 (file)
index 0000000..f13baba
--- /dev/null
@@ -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 (file)
index 0000000..452e0cf
--- /dev/null
@@ -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 (file)
index 0000000..f848bea
--- /dev/null
@@ -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 (file)
index 0000000..98f0434
--- /dev/null
@@ -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 (file)
index 0000000..5f4c128
--- /dev/null
@@ -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 (file)
index 0000000..a799b8a
--- /dev/null
@@ -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 (file)
index 0000000..40a935f
--- /dev/null
@@ -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 (file)
index 0000000..8eeb27d
--- /dev/null
@@ -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 (file)
index 0000000..113837a
--- /dev/null
@@ -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 (file)
index 0000000..fb04ea4
--- /dev/null
@@ -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 (file)
index 0000000..9a8e024
--- /dev/null
@@ -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 (file)
index 0000000..d4d7a84
--- /dev/null
@@ -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 (file)
index 0000000..38ed34d
--- /dev/null
@@ -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 (file)
index 0000000..8343f3d
--- /dev/null
@@ -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 (file)
index 0000000..5971142
--- /dev/null
@@ -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 (file)
index 0000000..10248a2
--- /dev/null
@@ -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 (file)
index 0000000..fb7a212
--- /dev/null
@@ -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 (file)
index 0000000..7c8e3cb
--- /dev/null
@@ -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 (file)
index 0000000..a5035cd
--- /dev/null
@@ -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 (file)
index 0000000..1b09d2d
--- /dev/null
@@ -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 (file)
index 0000000..ef489f5
--- /dev/null
@@ -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 (file)
index 0000000..cefbe45
--- /dev/null
@@ -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 (file)
index 0000000..38ad53f
--- /dev/null
@@ -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 `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>${pageTitle} - ${details.id} Details</title>
+    <link rel="stylesheet" href="../../static/theme.css">
+  </head>
+  <body>
+    <header>
+      <h1>Link Details</h1>
+    </header>
+    <main>
+      <div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
+      <div class="url">url: <span>${details.url}</span></div>
+      <div class="created">created: <time datetime="${created}">${created}</time></div>
+      <div class="accesses">accesses: <span>${details.accesses}</span></div>
+      <div class="lastaccess">last access: <time datetime="${lastAccess}">${lastAccess}</time></div>` +
+      (!details.expires ? '' : `
+      <div class="expires">expires: <time datetime="${expires}">${expires}</time></div>`) + `
+    </main>
+  </body>
+</html>`;
+};
\ No newline at end of file
diff --git a/src/template/report-html.js b/src/template/report-html.js
new file mode 100644 (file)
index 0000000..7313daf
--- /dev/null
@@ -0,0 +1,44 @@
+'use strict';
+
+const toDateString = (ts) => ts ? new Date(ts * 1000).toISOString() : 'never';
+
+module.exports = (ctx, links, pageTitle) => {
+  return `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <title>${pageTitle} - Link Report</title>
+    <link rel="stylesheet" href="../../static/theme.css">
+  </head>
+  <body>
+    <header>
+      <h1>Link Report</h1>
+    </header>
+    <main>
+      <table>
+        <thead>
+          <tr>
+            <th scope="col">id</th>
+            <th scope="col">url</th>
+            <th scope="col">accesses</th>
+            <th scope="col">created</th>
+            <th scope="col">expires</th>
+            <th scope="col">last access</th>
+          </tr>
+        </thead>
+        <tbody>` +
+  links.map((l) => `
+          <tr>
+            <th scope="row"><a href="/${l.id}/info">${l.id}</a></th>
+            <td>${l.url}</td>
+            <td>${l.accesses}</td>
+            <td>${toDateString(l.created)}</td>
+            <td>${toDateString(l.expires)}</td>
+            <td>${toDateString(l.lastAccess)}</td>
+          </tr>`).join('\n') + `
+        </tbody>
+      <table>
+    </main>
+  </body>
+</html>`;
+};
\ No newline at end of file
diff --git a/src/template/root-html.js b/src/template/root-html.js
new file mode 100644 (file)
index 0000000..65e34c7
--- /dev/null
@@ -0,0 +1,58 @@
+'use strict';
+
+module.exports = (ctx, pageTitle) => {
+  return `<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="robots" content="noindex">
+    <title>${pageTitle}</title>
+    <link rel="stylesheet" href="static/theme.css">
+  </head>
+  <body>
+  <script type="text/javascript">
+    document.addEventListener('DOMContentLoaded', function () {
+      document.querySelector('.copy-button').addEventListener('click', function(event) {
+        const linkHref = document.querySelector('.link').href;
+        navigator.clipboard.writeText(linkHref);
+        const copyButton = this;
+        copyButton.style.backgroundColor = 'lightgreen';
+        setTimeout(function () {
+          copyButton.style.backgroundColor = 'initial';
+        }, 3000);
+      });
+    });
+  </script>
+    <header>
+      <h1>${pageTitle}</h1>
+    </header>` +
+    (!ctx.createdLink ? '' : `
+    <section class="created-link">
+      <a class="src-link" href="${ctx.sourceLink}">${ctx.sourceLink}</a>
+      is now liked to by
+      <a class="link" href="${ctx.createdLink}">${ctx.createdLink}</a>
+      <span class="copy">
+        <button class="copy-button">Copy to Clipboard</button>
+      </span>
+      <div class="meta-link">
+        control token <span class="auth-token">${ctx.authToken}</span>
+      </div>
+    </section>`) +
+    (!ctx.message ? '' : `
+    <section class="message">
+      ${ctx.message}
+    </section>`) + `
+    <section class="create-link">
+      <form action="" method="post" class="submit-url">
+        <div class="submit-url">
+          <label for="url">URL: </label>
+          <input type="text" name="url" id="url" required>
+        </div>
+        <div class="submit-url">
+          <input type="submit" value="Create Link">
+        </div>
+      </form>
+    </section>
+  </body>
+</html>`;
+};
\ No newline at end of file
diff --git a/static/copy-link.svg b/static/copy-link.svg
new file mode 100644 (file)
index 0000000..5f961b5
--- /dev/null
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>\r
+<!-- Svg Vector Icons : http://www.onlinewebfonts.com/icon -->\r
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\r
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">\r
+<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>\r
+<g><path d="M500,10C229.4,10,10,229.4,10,500c0,270.6,219.4,490,490,490c270.6,0,490-219.4,490-490C990,229.4,770.6,10,500,10L500,10L500,10z M435.3,743.6c-49.3,49.3-129.6,49.3-179,0c-49.3-49.3-49.3-129.6,0-178.9l126.8-126.8c49.3-49.3,129.6-49.3,179,0c1.3,1.3,2.6,2.7,3.9,4.1c0.7,0.6,1.5,1.3,2.2,2c2,2,3.6,4.2,5,6.5c0.2,0.2,0.3,0.4,0.5,0.6c0,0-0.1,0.1-0.1,0.1c6.1,11.5,4.3,26-5.4,35.6c-9.7,9.7-24.4,11.5-35.9,5.2c-0.8,0.8-8.7-7.1-13-11.3l0,0c-25.8-25.8-67.3-26-93.1-0.2l-127,127c-25.8,25.8-25.8,67.5,0,93.3l0,0c25.8,25.8,67.5,25.8,93.3,0l76.4-76.4c26.5,13,57.7,13.4,84.5,1.1L435.3,743.6L435.3,743.6L435.3,743.6z M743.6,435.3L616.9,562.1c-49.3,49.3-129.6,49.3-179,0c-1.3-1.3-2.6-2.7-3.9-4.1c-0.7-0.6-1.5-1.3-2.1-2c-2-2-3.6-4.2-5-6.5c-0.2-0.2-0.3-0.4-0.5-0.6c0,0,0.1-0.1,0.1-0.1c-6.1-11.5-4.3-26,5.4-35.6c9.7-9.7,24.4-11.4,35.9-5.2c0.8-0.8,8.7,7.1,13,11.3l0,0c25.8,25.8,67.3,26,93.1,0.2l127-127c25.8-25.8,25.8-67.5,0-93.3l0,0c-25.8-25.8-67.5-25.8-93.3,0L531,375.6c-26.4-12.9-57.7-13.4-84.5-1.1l118.1-118.1c49.3-49.3,129.6-49.3,179,0C793,305.7,793,386,743.6,435.3L743.6,435.3L743.6,435.3z"/></g>\r
+</svg>
\ No newline at end of file
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644 (file)
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 (file)
index 0000000..f1c14e1
--- /dev/null
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+       <meta charset="utf-8">
+       <title>Static Assets</title>
+       <link rel="stylesheet" href="static/theme.css">
+</head>
+<body>
+       <header>
+               <h1>Static Assets</h1>
+       </header>
+       <main>
+               welcome to my static
+       </main>
+</body>
+</html>
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644 (file)
index 0000000..1f53798
--- /dev/null
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
diff --git a/static/theme.css b/static/theme.css
new file mode 100644 (file)
index 0000000..a0de35d
--- /dev/null
@@ -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 (file)
index 0000000..70ce764
--- /dev/null
@@ -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 (file)
index 0000000..25fc433
--- /dev/null
@@ -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 (file)
index 0000000..753f8c9
--- /dev/null
@@ -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 (file)
index 0000000..b94ca02
--- /dev/null
@@ -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 (file)
index 0000000..35a8328
--- /dev/null
@@ -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 (file)
index 0000000..0a1b803
--- /dev/null
@@ -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 (file)
index 0000000..ad1c11f
--- /dev/null
@@ -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 (file)
index 0000000..4e1d017
--- /dev/null
@@ -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 (file)
index 0000000..4b768c7
--- /dev/null
@@ -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