* 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
+
+Exists mostly as a testbed for libraries and experiments and such.
"version": "1.0.0",
"license": "ISC",
"dependencies": {
- "@squeep/api-dingus": "^2",
+ "@squeep/api-dingus": "file:../node-api-dingus",
+ "@squeep/cache": "file:../node-cache",
+ "@squeep/db-helper": "file:../node-db-helper",
"@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1",
+ "@squeep/log-helper": "^1.0.1",
"@squeep/logger-json-console": "^3",
"argon2": "^0.43.0",
"better-sqlite3": "^11",
},
"devDependencies": {
"@squeep/eslint-config": "^1",
- "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1",
+ "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.2",
"eslint": "^9",
"html-validate": "^9",
"husky": "^9",
"mocha": "^11",
+ "mocha-steps": "^1.3.0",
"nyc": "^17",
"sinon": "^21"
},
"node": ">=16.4"
}
},
+ "../node-api-dingus": {
+ "name": "@squeep/api-dingus",
+ "version": "2.1.3",
+ "license": "ISC",
+ "dependencies": {
+ "@squeep/log-helper": "^1",
+ "mime-db": "^1",
+ "uuid": "^11"
+ },
+ "devDependencies": {
+ "@squeep/eslint-config": "^1",
+ "eslint": "^9",
+ "husky": "^9",
+ "mocha": "^11",
+ "nyc": "^17",
+ "sinon": "^21"
+ },
+ "engines": {
+ "node": ">=14.13.1"
+ }
+ },
+ "../node-cache": {
+ "name": "@squeep/cache",
+ "version": "1.0.0",
+ "license": "ISC",
+ "devDependencies": {
+ "@squeep/eslint-config": "^1",
+ "eslint": "^9",
+ "husky": "^9",
+ "mocha": "^11",
+ "nyc": "^17",
+ "sinon": "^21"
+ }
+ },
+ "../node-db-helper": {
+ "name": "@squeep/db-helper",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "@squeep/log-helper": "^1",
+ "better-sqlite3": "^12",
+ "pg-promise": "^11",
+ "uuid": "^11"
+ },
+ "devDependencies": {
+ "@squeep/eslint-config": "^1",
+ "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1",
+ "eslint": "^9",
+ "husky": "^9",
+ "mocha": "^11",
+ "mocha-steps": "^1",
+ "nyc": "^17",
+ "sinon": "^21"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
}
},
"node_modules/@squeep/api-dingus": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/@squeep/api-dingus/-/api-dingus-2.1.3.tgz",
- "integrity": "sha512-e3olO2INqkUS2XjOnXEeRb0CWlwQ4AaB29hgTyx0Xy8/dRETD5fTXFSoNqGYjPJs29eFLkZR/Q0iBvG07IGesw==",
- "dependencies": {
- "@squeep/log-helper": "^1",
- "mime-db": "^1",
- "uuid": "^11"
- },
- "engines": {
- "node": ">=14.13.1"
- }
+ "resolved": "../node-api-dingus",
+ "link": true
+ },
+ "node_modules/@squeep/cache": {
+ "resolved": "../node-cache",
+ "link": true
+ },
+ "node_modules/@squeep/db-helper": {
+ "resolved": "../node-db-helper",
+ "link": true
},
"node_modules/@squeep/eslint-config": {
"version": "1.0.2",
}
},
"node_modules/@squeep/test-helper": {
- "version": "3.0.1",
- "resolved": "git+https://git.squeep.com/squeep-test-helper#062a2d18088fc8b41a126a0d8dcc3af6723d7bca",
- "dev": true
+ "version": "3.0.2",
+ "resolved": "git+https://git.squeep.com/squeep-test-helper#1104db678456d6cbe9139f7be35ce887abc9d655",
+ "dev": true,
+ "engines": {
+ "node": "^20.13 || >=22"
+ }
},
"node_modules/@tootallnate/quickjs-emscripten": {
"version": "0.23.0",
"node": ">=8.6"
}
},
- "node_modules/mime-db": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
- "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
+ "node_modules/mocha-steps": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/mocha-steps/-/mocha-steps-1.3.0.tgz",
+ "integrity": "sha512-KZvpMJTqzLZw3mOb+EEuYi4YZS41C9iTnb7skVFRxHjUd1OYbl64tCMSmpdIRM9LnwIrSOaRfPtNpF5msgv6Eg==",
+ "dev": true
+ },
"node_modules/mocha/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"author": "Justin Wind <jwind-urlittler@squeep.com>",
"license": "ISC",
"dependencies": {
- "@squeep/api-dingus": "^2",
+ "@squeep/api-dingus": "file:../node-api-dingus",
+ "@squeep/cache": "file:../node-cache",
+ "@squeep/db-helper": "file:../node-db-helper",
"@squeep/html-template-helper": "git+https://git.squeep.com/squeep-html-template-helper#v1.6.1",
+ "@squeep/log-helper": "^1.0.1",
"@squeep/logger-json-console": "^3",
"argon2": "^0.43.0",
"better-sqlite3": "^11",
},
"devDependencies": {
"@squeep/eslint-config": "^1",
- "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.1",
+ "@squeep/test-helper": "git+https://git.squeep.com/squeep-test-helper#v3.0.2",
"eslint": "^9",
- "husky": "^9",
"html-validate": "^9",
+ "husky": "^9",
"mocha": "^11",
+ "mocha-steps": "^1.3.0",
"nyc": "^17",
"sinon": "^21"
}
'use strict';
const http = require('node:http');
+const path = require('node:path');
const { AsyncLocalStorage } = require('node:async_hooks');
const DB = require('./src/db');
const Logger = require('./src/logger');
const Service = require('./src/service');
-const { fileScope } = require('./src/common');
+const { fileScope } = require('@squeep/log-helper');
const { version } = require('./package.json');
const _scope = fileScope(__filename)('main');
-const PORT = process.env.PORT || 3001;
+const PORT = parseInt(process.env.PORT ?? 3001, 10);
const ADDR = process.env.LISTEN_ADDR || '127.0.0.1';
-const asyncLocalStorage = new AsyncLocalStorage();
-const logger = new Logger({
- // ignoreBelowLevel: 'info',
-}, {}, asyncLocalStorage);
-const db = new DB(logger, {
- connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2F_db%2Fpostgres_dev-15/urlittler',
- // connectionString: 'sqlite://',
-});
-const service = new Service(logger, db, {
- proxyPrefix: process.env.PROXY_PREFIX || '', // '/lil',
- manager: {
- selfBaseUrl: process.env.BASE_URL || '',
+const config = {
+ dingus: {
+ proxyPrefix: process.env.PROXY_PREFIX || '', // e.g. '/lil',
+ selfBaseUrl: process.env.BASE_URL || `http://localhost:${PORT}/`,
+ staticPath: path.normalize(path.join(__dirname, 'static')),
},
-}, asyncLocalStorage);
+ db: {
+ connectionString: 'postgresql://%2Fhome%2Ffek%2Fprojects%2F_db%2Fpostgres_dev-15/urlittler',
+ // connectionString: 'sqlite://',
+ },
+};
+
+/**
+ *
+ */
+async function main() {
+ const asyncLocalStorage = new AsyncLocalStorage();
+ const logger = new Logger({
+ ignoreBelowLevel: 'debug',
+ }, {}, asyncLocalStorage);
+ const db = new DB(logger, config);
+ await db.initialize();
+
+ const service = new Service(logger, db, config.dingus, asyncLocalStorage);
-http.createServer((req, res) => {
- asyncLocalStorage.run({}, async () => {
- await service.dispatch(req, res);
+ const server = http.createServer();
+ server.on('request', (req, res) => {
+ asyncLocalStorage.run({}, async () => {
+ await service.dispatch(req, res);
+ });
});
-}).listen(PORT, ADDR, (err) => {
- if (err) {
- logger.error(_scope, 'error starting server:', err);
- throw err;
- }
- logger.info(_scope, 'server started', { version, listenAddress: ADDR, listenPort: PORT });
-});
+ server.on('error', (...args) => logger.error(_scope, 'server error', { args }));
+ server.on('listening', (...args) => logger.info(_scope, 'server listening', { ...args, PORT, ADDR, version }));
+ server.listen(PORT, ADDR);
+
+ // http.createServer((req, res) => {
+ // asyncLocalStorage.run({}, async () => {
+ // await service.dispatch(req, res);
+ // });
+ // }).listen(PORT, ADDR, (err) => {
+ // if (err) {
+ // logger.error(_scope, 'error starting server:', err);
+ // throw err;
+ // }
+ // logger.info(_scope, 'server started', { version, listenAddress: ADDR, listenPort: PORT });
+ // });
+}
+
+main().catch(console.error);
const common = require('./common');
const Enum = require('./enum');
const { ResponseError } = require('./errors');
+const { fileScope } = require('@squeep/log-helper');
-const _fileScope = common.fileScope(__filename);
+const _fileScope = fileScope(__filename);
/**
* Let us roll our own authentication dingus.
* @param {http.ClientRequest} req request
* @param {http.ServerResponse} res response
* @param {object} ctx context
- * @returns {Promise<void>}
+ * @returns {Promise<boolean>} valid
*/
async required(req, res, ctx) {
const _scope = _fileScope('required');
--- /dev/null
+'use strict';
+
+const { Abstract } = require('@squeep/db-helper');
+
+class Database extends Abstract {
+
+ /**
+ * Interface
+ */
+ /* eslint-disable no-unused-vars */
+
+ /**
+ * @typedef {object} Auth
+ * @property {string} id id
+ * @property {string} secret secret
+ * @property {string=} password credential
+ */
+
+ /**
+ * @typedef {object} Link
+ * @property {string} id id
+ * @property {string} url url
+ * @property {Date} created created
+ * @property {Date|Infinity} lastAccess last access
+ * @property {number} accesses accesses
+ * @property {Date=} expires expires
+ * @property {string=} authToken auth token
+ * @property {boolean} isSpecial special link
+ */
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @returns {Promise<Auth|undefined>} auth
+ */
+ async getAuthById(dbCtx, id) {
+ this._notImplemented('getAuthById', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @param {string} secret secret
+ * @param {string=} credential credential
+ * @returns {Promise<void>}
+ */
+ async upsertAuth(dbCtx, id, secret, credential) {
+ this._notImplemented('upsertAuthCredential', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @param {string} url string
+ * @param {string=} authToken token
+ * @returns {Promise<void>}
+ */
+ async upsertLink(dbCtx, id, url, authToken) {
+ this._notImplemented('upsertLink', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @returns {Promise<Link|undefined>} link
+ */
+ async getLinkById(dbCtx, id) {
+ this._notImplemented('getLinkById', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} url url
+ * @returns {Promise<Link|undefined>} link
+ */
+ async getLinkByUrl(dbCtx, url) {
+ this._notImplemented('getLinkByUrl', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @returns {Promise<Link|undefined>} link
+ */
+ async accessLink(dbCtx, id) {
+ this._notImplemented('accessLink', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @param {string} id id
+ * @param {Date} expires expire date
+ * @returns {Promise<void>}
+ */
+ async expireLink(dbCtx, id, expires) {
+ this._notImplemented('expireLink', arguments);
+ }
+
+ /**
+ *
+ * @param {*} dbCtx db context
+ * @returns {Promise<Link[]>} links
+ */
+ async getAllLinks(dbCtx) {
+ this._notImplemented('getAllLinks', arguments);
+ }
+
+}
+
+module.exports = Database;
\ No newline at end of file
this._notImplemented('upsertAuthCredential', { dbCtx, id, credential });
}
- async insertLink(dbCtx, id, url, authToken) {
- this._notImplemented('insertLink', { dbCtx, id, url, authToken });
+ async upsertLink(dbCtx, id, url, authToken) {
+ this._notImplemented('upsertLink', { dbCtx, id, url, authToken });
}
async getLinkById(dbCtx, id) {
this._notImplemented('expireLink', { dbCtx, id, expires });
}
- async updateLink(dbCtx, id, url) {
- this._notImplemented('updateLink', { dbCtx, id, url });
- }
-
async getAllLinks(dbCtx) {
this._notImplemented('getAllLinks', { dbCtx });
}
--- /dev/null
+'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 = { ...defaultOptions, ...options };
+ const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase();
+ switch (protocol) {
+ case DatabaseFactory.Engines.PostgreSQL: {
+ const Postgres = require('./postgres-old');
+ return new Postgres(logger, options, ...rest);
+ }
+
+ case DatabaseFactory.Engines.SQLite: {
+ const SQLite = require('./sqlite-old');
+ 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;
'use strict';
-const common = require('../common');
-const DBErrors = require('./errors');
+const { Factory } = require('@squeep/db-helper');
-const _fileScope = common.fileScope(__filename);
-
-const defaultOptions = {
- connectionString: '',
-};
-
-class DatabaseFactory {
- constructor(logger, options, ...rest) {
- const _scope = _fileScope + ':constructor';
- options = { ...defaultOptions, ...options };
- const protocol = options.connectionString.slice(0, options.connectionString.indexOf('://')).toLowerCase();
- 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);
- }
+class DatabaseFactory extends Factory {
+ constructor(logger, options, ...engineArguments) {
+ super('.', logger, options, ...engineArguments);
}
- static get Engines() {
- return {
- PostgreSQL: 'postgresql',
- SQLite: 'sqlite',
- };
+ // eslint-disable-next-line class-methods-use-this
+ localRequire(enginePath) {
+ return require(enginePath); // eslint-disable-line security/detect-non-literal-require
}
-}
+} // DatabaseFactory
-module.exports = DatabaseFactory;
+module.exports = DatabaseFactory;
\ No newline at end of file
};
}
- async insertLink(dbCtx, id, url, authToken) {
- const _scope = _fileScope('insertLink');
+ async upsertLink(dbCtx, id, url, authToken) {
+ const _scope = _fileScope('upsertLink');
this.logger.debug(_scope, 'called', { id, url, authToken });
dbCtx = dbCtx || this.db;
}
- 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', { });
);
-- system entries
- INSERT INTO link (id, url) VALUES ('static', '/static/index.html'), ('favicon.ico', '/static/favicon.ico'), ('robots.txt', '/static/robots.txt');
+ INSERT INTO link (id, url) VALUES
+ ('static', '/static/index.html'),
+ ('favicon.ico', '/static/favicon.ico'),
+ ('robots.txt', '/static/robots.txt')
+ ;
-- migration complete
INSERT INTO _meta_schema_version (major, minor, patch) VALUES (1, 0, 0);
--- /dev/null
+'use strict';
+
+const { PostgresCreator, Errors: { UnexpectedResult } } = require('@squeep/db-helper');
+const { fileScope } = require('@squeep/log-helper');
+const Abstract = require('../abstract');
+const assert = require('node:assert');
+
+const _fileScope = fileScope(__filename);
+
+const schemaVersionsSupported = {
+ min: {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ },
+ max: {
+ major: 1,
+ minor: 0,
+ patch: 1,
+ },
+};
+
+class DatabasePostgres extends PostgresCreator(Abstract) {
+
+ constructor(...args) {
+ super(...args);
+ /* istanbul ignore next */
+ if (!this._isProduction) {
+ this._tableNames.push(
+ 'link',
+ 'auth',
+ );
+ }
+ }
+
+ // eslint-disable-next-line class-methods-use-this
+ get schemaVersionsSupported() {
+ return schemaVersionsSupported;
+ }
+
+
+ async initialize(sqlPath = __dirname, applyMigrations = true) {
+ /* istanbul ignore next */
+ await super.initialize(sqlPath, applyMigrations);
+ }
+
+
+ static _authDbToNative(auth) {
+ return auth && {
+ ...auth,
+ };
+ }
+
+
+ static _linkDbToNative(link) {
+ return link && {
+ ...link,
+ };
+ }
+
+
+ async getAuthById(dbCtx, id) {
+ const _scope = _fileScope('getAuthById');
+ this.logger.debug(_scope, 'called', { id });
+
+ try {
+ const auth = await dbCtx.oneOrNone(this.statement.authGetById, { id });
+ return this.constructor._authDbToNative(auth);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
+ }
+
+
+ async upsertAuth(dbCtx, id, secret, credential) {
+ const _scope = _fileScope('upsertAuth');
+ this.logger.debug(_scope, 'called', { id });
+
+ try {
+ const result = await dbCtx.result(this.statement.authUpsert, { id, secret, credential });
+ if (result.rowCount != 1) {
+ throw new UnexpectedResult('did not insert auth');
+ }
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
+ }
+
+
+ async upsertLink(dbCtx, id, url, authToken) {
+ const _scope = _fileScope('upsertLink');
+ this.logger.debug(_scope, 'called', { id, url, authToken });
+
+ try {
+ const result = await dbCtx.result(this.statement.linkUpsert, { id, url, authToken });
+ if (result.rowCount != 1) {
+ throw new UnexpectedResult('did not insert link');
+ }
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id, url, authToken });
+ throw err;
+ }
+ }
+
+
+ async getLinkById(dbCtx, id) {
+ const _scope = _fileScope('getLinkById');
+ this.logger.debug(_scope, 'called', { id });
+
+ try {
+ const link = await dbCtx.oneOrNone(this.statement.linkGetById, { id });
+ return this.constructor._linkDbToNative(link);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
+ }
+
+
+ async getLinkByUrl(dbCtx, url) {
+ const _scope = _fileScope('getLinkByUrl');
+ this.logger.debug(_scope, 'called', { url });
+
+ try {
+ const link = await dbCtx.oneOrNone(this.statement.linkGetByUrl, { url });
+ return this.constructor._linkDbToNative(link);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, url });
+ throw err;
+ }
+ }
+
+
+ async accessLink(dbCtx, id) {
+ const _scope = _fileScope('accessLink');
+ this.logger.debug(_scope, 'called', { id });
+
+ try {
+ return await dbCtx.oneOrNone(this.statement.linkAccess, { id });
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
+ }
+
+
+ async expireLink(dbCtx, id, expires) {
+ const _scope = _fileScope('expireLink');
+ this.logger.debug(_scope, 'called', { id, expires });
+
+ assert(expires instanceof Date);
+
+ try {
+ const result = await dbCtx.result(this.statement.linkExpire, { expires, id });
+ if (result.rowCount != 1) {
+ throw new UnexpectedResult('did not expire link');
+ }
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { id, expires });
+ throw err;
+ }
+ }
+
+
+ async getAllLinks(dbCtx) {
+ const _scope = _fileScope('getAllLinks');
+ this.logger.debug(_scope, 'called', { });
+
+ try {
+ const links = await dbCtx.manyOrNone(this.statement.linkGetAll);
+ return links.map((link) => this.constructor._linkDbToNative(link));
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err });
+ throw err;
+ }
+ }
+
+} // DatabasePostgres
+
+module.exports = DatabasePostgres;
\ No newline at end of file
--- /dev/null
+SELECT * FROM auth WHERE id = $(id)
\ No newline at end of file
--- /dev/null
+--
+INSERT INTO auth (id, secret, password) VALUES ($(id), $(secret), $(credential))
+ON CONFLICT (id) DO
+UPDATE SET password = $(credential), secret = $(secret)
--- /dev/null
+UPDATE link
+SET
+ accesses = accesses + 1,
+ last_access = now()
+WHERE id = $(id)
+RETURNING *
--- /dev/null
+UPDATE link
+SET
+ expires = $(expires)
+WHERE id = $(id)
--- /dev/null
+SELECT *
+FROM link
+ORDER BY id
\ No newline at end of file
--- /dev/null
+SELECT *
+FROM link WHERE id = $(id)
\ No newline at end of file
--- /dev/null
+SELECT *
+FROM link WHERE url = $(url)
\ No newline at end of file
--- /dev/null
+INSERT INTO link (id, url, auth_token)
+ VALUES
+ ($(id), $(url), $(authToken))
+ ON CONFLICT (id) DO UPDATE
+ SET
+ url = $(url)
+RETURNING *
\ No newline at end of file
--- /dev/null
+-- 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_link();
+
+-- better auth TBD
+CREATE TABLE auth (
+ id TEXT NOT NULL PRIMARY KEY,
+ secret TEXT NOT NULL,
+ password TEXT
+);
+
+-- system entries
+INSERT INTO link (id, url) VALUES
+ ('static', '/static/index.html'),
+ ('favicon.ico', '/static/favicon.ico'),
+ ('robots.txt', '/static/robots.txt')
+;
--- /dev/null
+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;
--- /dev/null
+ALTER TABLE link
+ ADD COLUMN is_special BOOLEAN NOT NULL DEFAULT false
+;
+
+UPDATE link
+ SET is_special = true
+WHERE
+ url IN ('/static/robots.txt', '/static/index.html', '/static/favicon.ico');
--- /dev/null
+BEGIN;
+
+ ALTER TABLE link
+ DROP COLUMN is_special
+ ;
+
+ DELETE FROM _meta_schema_version WHERE major = 1 AND minor = 0 AND patch = 1;
+
+COMMIT;
--- /dev/null
+'use strict';
+
+const SQLite = require('better-sqlite3');
+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);
+
+ this.db.pragma('foreign_keys = on');
+ this.db.pragma('journal_mode = WAL');
+ this.db.defaultSafeIntegers(true);
+
+ this._initTables();
+ this._initStatements();
+ }
+
+ async initialize() {
+ this._x = true;
+ }
+
+ healthCheck() {
+ const _scope = _fileScope('healthCheck');
+ this.logger.debug(_scope, 'called', {});
+ if (!this.db.open) {
+ throw new DBErrors.UnexpectedResult('database is not open');
+ }
+ return { open: this.db.open };
+ }
+
+ _closeConnection() {
+ this.db.close();
+ }
+
+ _purgeTables(really) {
+ if (really) {
+ const extantTableNames = ['auth', 'link'];
+ extantTableNames.forEach((table) => {
+ const result = this.db.prepare(`DELETE FROM ${table}`).run();
+ this.logger.debug(_fileScope('_purgeTables'), 'success', { table, result });
+ });
+ }
+ }
+
+ _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 CHECK (typeof(id) = 'text'),
+ secret TEXT NOT NULL CHECK (typeof(secret) = 'text'),
+ password TEXT CHECK (typeof(password) IN ('text', 'null'))
+ )`,
+ `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 CHECK (typeof(id) = 'text'),
+ url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'),
+ created INTEGER NOT NULL DEFAULT ${EPOCH_NOW} CHECK (typeof(created) = 'integer'),
+ last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'),
+ accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'),
+ expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')),
+ auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null')),
+ is_special INTEGER NOT NULL DEFAULT 0 CHECK (is_special IN (0, 1))
+ )`,
+ '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 = {
+ _beginImmediate: this.db.prepare('BEGIN IMMEDIATE'),
+ _commit: this.db.prepare('COMMIT'),
+ _rollback: this.db.prepare('ROLLBACK'),
+ getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'),
+ insertAuth: this.db.prepare('INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential)'),
+ updateAuth: this.db.prepare('UPDATE auth SET password = :credential, secret = :secret WHERE id = :id'),
+ getLinkById: this.db.prepare('SELECT * FROM link WHERE id = :id'),
+ getLinkByUrl: this.db.prepare('SELECT * FROM link WHERE url = :url'),
+ upsertLink: 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;
+ try {
+ this.statement._beginImmediate.run();
+ const result = await fn(dbCtx, ...rest);
+ this.statement._commit.run();
+ return result;
+ } finally {
+ if (this.db.inTransaction) {
+ this.statement._rollback.run();
+ }
+ }
+ }
+
+ 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 upsertAuth(dbCtx, id, secret, credential) {
+ const _scope = _fileScope('upsertAuthCredential');
+ this.logger.debug(_scope, 'called', { id });
+
+ let info;
+ try {
+ info = this.statement.insertAuth.run({ id, secret, credential });
+ } catch (e) {
+ switch (e.code) {
+ case 'SQLITE_CONSTRAINT_UNIQUE':
+ case 'SQLITE_CONSTRAINT_PRIMARYKEY': {
+ this.logger.debug(_scope, 'updating existing auth', { id });
+ info = this.statement.updateAuth.run({ id, secret, credential });
+ break;
+ }
+
+ default: {
+ this.logger.error(_scope, 'failed to upsert auth credential', { error: e, id });
+ throw e;
+ }
+ }
+ }
+ this.logger.debug(_scope, 'run', { info });
+ if (info.changes != 1) {
+ this.logger.error(_scope, 'failed to upsert auth credential', { id, info });
+ throw new DBErrors.UnexpectedResult();
+ }
+
+ return this._sqliteInfo(info);
+ }
+
+ async upsertLink(dbCtx, id, url, authToken) {
+ const _scope = _fileScope('upsertLink');
+ this.logger.debug(_scope, 'called', { id, url });
+
+ let info;
+ try {
+ info = this.statement.upsertLink.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,
+ isSpecial: !! link.isSpecial,
+ };
+ }
+
+ 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);
+ }
+
+ // 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;
'use strict';
-const SQLite = require('better-sqlite3');
-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 { SQLiteCreator, Errors: { UnexpectedResult } } = require('@squeep/db-helper');
+const { fileScope } = require('@squeep/log-helper');
+const Abstract = require('../abstract');
+const assert = require('node:assert'); // temporary validations during db interface migration
+
+const _fileScope = fileScope(__filename);
+
+const schemaVersionsSupported = {
+ min: {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ },
+ max: {
+ major: 1,
+ minor: 0,
+ patch: 1,
+ },
};
-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);
-
- this.db.pragma('foreign_keys = on');
- this.db.pragma('journal_mode = WAL');
- this.db.defaultSafeIntegers(true);
+class DatabaseSQLite extends SQLiteCreator(Abstract) {
- this._initTables();
- this._initStatements();
+ constructor(...args) {
+ super(...args);
+ /* istanbul ignore next */
+ if (!this._isProduction) {
+ this._tableNames.push(
+ 'link',
+ 'auth',
+ );
+ }
}
- _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 CHECK (typeof(id) = 'text'),
- secret TEXT NOT NULL CHECK (typeof(secret) = 'text'),
- password TEXT CHECK (typeof(password) IN ('text', 'null'))
- )`,
- `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 CHECK (typeof(id) = 'text'),
- url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'),
- created INTEGER NOT NULL DEFAULT ${EPOCH_NOW} CHECK (typeof(created) = 'integer'),
- last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'),
- accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'),
- expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')),
- auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null')),
- is_special INTEGER NOT NULL DEFAULT 0 CHECK (is_special IN (0, 1))
- )`,
- '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();
- });
- }
- });
+ // eslint-disable-next-line class-methods-use-this
+ get schemaVersionsSupported() {
+ return schemaVersionsSupported;
}
- _initStatements() {
- this.statement = {
- _beginImmediate: this.db.prepare('BEGIN IMMEDIATE'),
- _commit: this.db.prepare('COMMIT'),
- _rollback: this.db.prepare('ROLLBACK'),
- getAuthById: this.db.prepare('SELECT * FROM auth WHERE id = :id'),
- insertAuth: this.db.prepare('INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential)'),
- updateAuth: this.db.prepare('UPDATE auth SET password = :credential, secret = :secret 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);
+ async initialize(sqlPath = __dirname, applyMigrations = true) {
+ /* istanbul ignore next */
+ await super.initialize(sqlPath, applyMigrations);
}
- _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 _authDbToNative(auth) {
+ return auth && {
+ ...auth,
};
}
- 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);
+ static _linkDbToNative(link) {
+ return link && {
+ ...link,
+ created: this._epochToDate(link.created),
+ lastAccess: (link.lastAccess > 0) ? this._epochToDate(link.lastAccess) : -Infinity,
+ accesses: Number(link.accesses),
+ expires: (link.expires != null) ? this._epochToDate(link.expires) : null,
+ isSpecial: !! link.isSpecial,
+ };
}
- async transaction(dbCtx, fn, ...rest) {
- dbCtx = dbCtx || this.db;
- try {
- this.statement._beginImmediate.run();
- const result = await fn(dbCtx, ...rest);
- this.statement._commit.run();
- return result;
- } finally {
- if (this.db.inTransaction) {
- this.statement._rollback.run();
- }
- }
- }
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;
+ try {
+ const auth = this.statement.authGetById.get({ id });
+ return this.constructor._authDbToNative(auth);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
+
}
+
async upsertAuth(dbCtx, id, secret, credential) {
- const _scope = _fileScope('upsertAuthCredential');
+ const _scope = _fileScope('upsertAuth');
this.logger.debug(_scope, 'called', { id });
- let info;
try {
- info = this.statement.insertAuth.run({ id, secret, credential });
- } catch (e) {
- switch (e.code) {
- case 'SQLITE_CONSTRAINT_UNIQUE':
- case 'SQLITE_CONSTRAINT_PRIMARYKEY': {
- this.logger.debug(_scope, 'updating existing auth', { id });
- info = this.statement.updateAuth.run({ id, secret, credential });
- break;
- }
-
- default: {
- this.logger.error(_scope, 'failed to upsert auth credential', { error: e, id });
- throw e;
+ return await this.transaction(dbCtx, async () => {
+ try {
+ const result = this.statement.authInsert.run({ id, secret, credential });
+ if (result.changes != 1) {
+ this.logger.error(_scope, 'failed to insert auth entry', { result, id });
+ throw new UnexpectedResult('failed to insert auth');
+ }
+ } catch (e) {
+ switch (e.code) {
+ case 'SQLITE_CONSTRAINT_UNIQUE':
+ case 'SQLITE_CONSTRAINT_PRIMARYKEY': {
+ const result = this.statement.authUpdate.run({ id, secret, credential });
+ if (result.changes != 1) {
+ this.logger.error(_scope, 'failed to update auth entry', { result, id });
+ throw new UnexpectedResult('failed to update auth');
+ }
+ break;
+ }
+
+ default:
+ throw e;
+ }
}
- }
- }
- this.logger.debug(_scope, 'run', { info });
- if (info.changes != 1) {
- this.logger.error(_scope, 'failed to upsert auth credential', { id, info });
- throw new DBErrors.UnexpectedResult();
+ }); // tx
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
}
-
- return this._sqliteInfo(info);
}
- async insertLink(dbCtx, id, url, authToken) {
- const _scope = _fileScope('insertLink');
+
+ async upsertLink(dbCtx, id, url, authToken) {
+ const _scope = _fileScope('upsertLink');
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;
+ return await this.transaction(dbCtx, async () => {
+ try {
+ const result = this.statement.linkInsert.run({ id, url, authToken });
+ if (result.changes != 1) {
+ this.logger.error(_scope, 'failed to insert link', { id, url, result });
+ throw new UnexpectedResult('failed to insert link');
+ }
+ } catch (e) {
+ switch (e.code) {
+ case 'SQLITE_CONSTRAINT_UNIQUE':
+ case 'SQLITE_CONSTRAINT_PRIMARYKEY': {
+ const result = this.statement.linkUpdate.run({ id, url });
+ if (result.changes != 1) {
+ this.logger.error(_scope, 'failed to update link', { id, url, result });
+ throw new UnexpectedResult('failed to update link');
+ }
+ break;
+ }
+
+ default:
+ throw e;
+ }
}
-
- default: {
- this.logger.error(_scope, 'failed to insert link', { error: e, id, url });
- throw e;
- }
- }
+ }); // tx
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id, url });
+ throw err;
}
- 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,
- isSpecial: !! link.isSpecial,
- };
- }
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);
+ try {
+ const link = this.statement.linkGetById.get({ id });
+ return this.constructor._linkDbToNative(link);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
+ }
}
+
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);
+ try {
+ const link = this.statement.linkGetByUrl.get({ url });
+ return this.constructor._linkDbToNative(link);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, url });
+ throw err;
+ }
}
+
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 await this.transaction(dbCtx, async () => {
+ try {
+ const link = this.statement.linkGetById.get({ id });
+ if (link) {
+ const result = this.statement.linkAccess.run({ id });
+ if (result.changes != 1) {
+ this.logger.error(_scope, 'failed to increment link access', { id, link, result });
+ throw new UnexpectedResult('failed to increment link access');
+ }
+ }
+ return this.constructor._linkDbToNative(link);
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id });
+ throw err;
}
- }
- return SQLiteDatabase._linkToNative(link);
+ }); // tx
}
+
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);
- }
+ this.logger.debug(_scope, 'called', { id, expires });
- async updateLink(dbCtx, id, url) {
- const _scope = _fileScope('updateLink');
- this.logger.debug(_scope, 'called', { id, url });
+ assert(expires instanceof Date);
- const info = this.statement.updateLink.run({ id, url });
- if (info.changes != 1) {
- throw new DBErrors.UnexpectedResult();
- }
- return this._sqliteInfo(info);
+ try {
+ const result = this.statement.linkExpire.run({ id, expires: this.constructor._dateToEpoch(expires) });
+ if (result.changes != 1) {
+ throw new UnexpectedResult('failed to update link expiration');
+ }
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err, id, expires });
+ throw err;
+ }
}
+
// 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;
+
+ try {
+ const links = this.statement.linkGetAll.all({});
+ return links.map((l) => this.constructor._linkDbToNative(l));
+ } catch (err) {
+ this.logger.error(_scope, 'failed', { error: err });
+ throw err;
+ }
}
+
}
-module.exports = SQLiteDatabase;
+module.exports = DatabaseSQLite;
\ No newline at end of file
--- /dev/null
+SELECT * FROM auth WHERE id = :id
--- /dev/null
+INSERT INTO auth (id, secret, password) VALUES (:id, :secret, :credential)
--- /dev/null
+UPDATE auth SET password = :credential, secret = :secret WHERE id = :id
--- /dev/null
+UPDATE link SET accesses = accesses + 1, last_access = (strftime('%s', 'now')) WHERE id = :id
--- /dev/null
+UPDATE link SET expires = :expires WHERE id = :id
--- /dev/null
+SELECT * FROM link
--- /dev/null
+SELECT * FROM link WHERE id = :id
--- /dev/null
+SELECT * FROM link WHERE url = :url
--- /dev/null
+INSERT INTO link (id, url, auth_token) VALUES (:id, :url, :authToken)
--- /dev/null
+UPDATE link SET url = :url WHERE id = :id
--- /dev/null
+
+CREATE TABLE IF NOT EXISTS auth (
+ id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'),
+ secret TEXT NOT NULL CHECK (typeof(secret) = 'text'),
+ password TEXT CHECK (typeof(password) IN ('text', 'null'))
+);
+
+CREATE TABLE IF NOT EXISTS link (
+ id TEXT NOT NULL PRIMARY KEY CHECK (typeof(id) = 'text'),
+ url TEXT NOT NULL UNIQUE CHECK (typeof(url) = 'text'),
+ created INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) CHECK (typeof(created) = 'integer'),
+ last_access INTEGER NOT NULL DEFAULT 0 CHECK (typeof(last_access) = 'integer'),
+ accesses INTEGER NOT NULL DEFAULT 0 CHECK (typeof(accesses) = 'integer'),
+ expires INTEGER CHECK (typeof(expires) IN ('integer', 'null')),
+ auth_token TEXT CHECK (typeof(auth_token) IN ('text', 'null'))
+);
+
+INSERT INTO link (id, url) VALUES
+ ('static', '/static/index.html'),
+ ('favicon.ico', '/static/favicon.ico'),
+ ('robots.txt', '/static/robots.txt')
+;
--- /dev/null
+BEGIN;
+
+ DROP TABLE link;
+ DROP TABLE auth;
+
+COMMIT;
--- /dev/null
+ALTER TABLE link
+ ADD COLUMN is_special BOOLEAN NOT NULL DEFAULT false
+;
+
+UPDATE link
+ SET is_special = true
+WHERE
+ url IN (
+ '/static/robots.txt',
+ '/static/index.html',
+ '/static/favicon.ico'
+ );
--- /dev/null
+BEGIN;
+
+ALTER TABLE link
+ DROP COLUMN is_special
+
+COMMIT;
const { ResponseError, ServeStaticFile, SlugGeneratorExhausted } = require('./errors');
const { makeSlugGenerator } = require('./slug');
const Template = require('./template');
+const { fileScope } = require('@squeep/log-helper');
-const _fileScope = common.fileScope(__filename);
+const _fileScope = fileScope(__filename);
const defaultOptions = {
pageTitle: require('../package.json').name,
logoUrl: '/static/muRL.png',
selfBaseUrl: '',
- staticDirectory: '/static/',
+ staticRoutePrefix: '/static/',
};
/**
const _scope = _fileScope('_validateContextURL');
// Admin is allowed to create locally served entries.
- if (ctx?.sourceLink?.startsWith(this.staticDirectory)
+ if (ctx?.sourceLink?.startsWith(this.staticRoutePrefix)
&& ctx.authenticationId) {
return;
}
throw new ResponseError(Enum.ErrorResponse.Gone);
}
- if (link.url.startsWith(this.staticDirectory)) {
+ if (link.url.startsWith(this.staticRoutePrefix)) {
/* 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);
+ const fileName = link.url.slice(this.staticRoutePrefix.length);
this.logger.info(_scope, 'static link', { link, ctx });
throw new ServeStaticFile(fileName);
}
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');
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);
+ await this.db.upsertLink(txCtx, id, ctx.sourceLink, ctx.authToken);
ctx.createdLink = `${this.selfBaseUrl}/${id}`;
this.logger.info(_scope, 'created new link', { id, ctx });
}
- content = this.rootContent(ctx);
+ const content = this.rootContent(ctx);
return res.end(content);
});
});
// 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);
+ throw new ResponseError(Enum.ErrorResponse.Forbidden, 'Supplied credentials do not allow PUT creation.');
}
res.statusCode = 201; // Created
ctx.authToken = await Authenticator.generateToken();
- this.db.insertLink(txCtx, ctx.params.id, ctx.sourceLink, ctx.authToken);
+ await this.db.upsertLink(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);
+ await this.db.upsertLink(txCtx, existingLink.id, ctx.sourceLink, existingLink.token);
ctx.createdLink = `${this.selfBaseUrl}/${existingLink.id}`;
this.logger.info(_scope, 'updated link', { ctx });
}
- });
+ }); // tx
});
const content = this.rootContent(ctx);
const _scope = _fileScope('deleteById');
this.logger.debug(_scope, 'called', { ctx });
+ const now = new Date();
let link;
await this.db.context(async (dbCtx) => {
await this.db.transaction(dbCtx, async (txCtx) => {
this.logger.debug(_scope, 'no such id', { ctx });
throw new ResponseError(Enum.ErrorResponse.InvalidLinkId);
}
- if (link.expires && link.expires < Date.now()) {
+ if (link.expires && link.expires < now) {
+ this.logger.debug(_scope, 'already expired', { ctx });
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 });
+ await this.db.expireLink(txCtx, ctx.params.id, now);
+ this.logger.info(_scope, 'expired link', { ctx, now });
res.statusCode = 204; // No Content
});
});
const { ServeStaticFile } = require('./errors');
const Authenticator = require('./authenticator');
const Manager = require('./manager');
+const { fileScope } = require('@squeep/log-helper');
-const _fileScope = common.fileScope(__filename);
+const _fileScope = fileScope(__filename);
const defaultOptions = {
ignoreTrailingSlash: true,
- staticDirectory: path.join(__dirname, '..', 'static'),
+ staticPath: path.join(__dirname, '..', 'static'),
createRequiresAuth: false,
proxyPrefix: '',
};
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.manager = new Manager(logger, db, options);
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));
+ this.on(['GET'], '/', 'root', this.handlerGetRoot.bind(this));
+ this.on(['POST'], '/', this.handlerPostRoot.bind(this));
+
+ this.on(['GET'], '/:id', 'murl', this.handlerGetId.bind(this));
+ this.on(['DELETE'], '/:id', this.handlerDeleteId.bind(this));
+ this.on(['PUT'], '/:id', this.handlerPutId.bind(this));
+
+ this.on(['GET'], '/:id/info', this.handlerGetIdInfo.bind(this));
+ this.on(['GET'], '/admin/report', this.handlerGetAdminReport.bind(this));
+
+ this.defineStandardStaticRoutes(options);
}
/**
* Return a content-type appropriate rendering of an errorResponse object.
* @param {string} contentType content type of response
- * @param {Enum.ResponseError|Exception} err error
+ * @param {Enum.ResponseError|Error} err error
* @returns {string} rendered error
*/
renderError(contentType, err) {
Dingus.setEndBodyHandler(req, res, ctx, this._endHandler.bind(this));
const logObject = this.asyncLocalStorage.getStore();
+ /* istanbul ignore next */
if (logObject) {
logObject.requestId = ctx.requestId;
delete ctx.requestId;
this.authenticator.signResponse(req, res, ctx);
}
+
/**
* @param {http.ClientRequest} req request
* @param {http.ServerResponse} res resopnse
try {
await this.manager.getById(res, ctx);
} catch (e) {
+ // Manager can throw ServeStaticFile when id maps to a static file.
if (e instanceof ServeStaticFile
&& e.file) {
- return await this.serveFile(req, res, ctx, this.staticDirectory, e.file);
+ return await this.serveFile(req, res, ctx, this.staticPath, e.file);
}
throw e;
}
}
- /**
- * @param {http.ClientRequest} req request
- * @param {http.ServerResponse} res response
- * @param {object} ctx conetxt
- * @returns {Promise<void>}
- */
- async handlerGetStatic(req, res, ctx) {
- const _scope = _fileScope('handlerGetStatic');
- this.logger.debug(_scope, 'called', { 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 request
* @param {http.ServerResponse} res response
const { TemplateHelper: th } = require('@squeep/html-template-helper');
-const toDateString = (ts) => {
- try {
- return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never';
- } catch (e) { // eslint-disable-line no-unused-vars
- return `(whatever '${ts.toString()}' is)`;
- }
-};
-
-
/**
*
* @param {object} details link details
* @returns {string} HTML details section
*/
function renderDetailsSection(details) {
- const created = toDateString(details.created);
- const lastAccess = toDateString(details.lastAccess);
- const expires = details.expires ? toDateString(details.expires) : '';
+ const created = th.dateFormat(details.created);
+ const lastAccess = th.dateFormat(details.lastAccess, 'never', 'never', 'never');
+ const expires = th.dateFormat(details.expires, 'never', 'forever', 'never');
return `<section>
\t<div class="identifier">id: <span><a href="/${details.id}">${details.id}</a></span></div>
const { TemplateHelper: th } = require('@squeep/html-template-helper');
-const toDateString = (ts) => {
- try {
- return (ts && isFinite(ts)) ? (new Date(ts * 1000).toISOString()) : 'never';
- } catch (e) { // eslint-disable-line no-unused-vars
- return `(whatever '${ts.toString()}' is)`;
- }
-};
-
-
/**
*
* @returns {string} HTML table header row
\t<th scope="row"><a href="/${link.id}/info">${link.id}</a></th>
\t<td>${link.url}</td>
\t<td>${link.accesses}</td>
-\t<td>${toDateString(link.created)}</td>
-\t<td>${toDateString(link.expires)}</td>
-\t<td>${toDateString(link.lastAccess)}</td>
+\t<td>${th.dateFormat(link.created)}</td>
+\t<td>${th.dateFormat(link.expires, 'never', 'forever', 'never')}</td>
+\t<td>${th.dateFormat(link.lastAccess, 'never', 'never', 'never')}</td>
</tr>`;
}
--- /dev/null
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+
+const { StubLogger } = require('@squeep/test-helper');
+const { test: { interfaceMethods }, Errors: { NotImplemented } } = require('@squeep/db-helper');
+const Abstract = require('../../../src/db/abstract');
+
+describe('Abstract', function () {
+ let db, stubLogger;
+ beforeEach(function () {
+ stubLogger = new StubLogger(sinon);
+ db = new Abstract(stubLogger);
+ });
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ it('covers no options', function () {
+ db = new Abstract();
+ });
+
+ describe('Interface', function () {
+ it('covers supported versions', function () {
+ const { min, max } = db.schemaVersionsSupported;
+ assert('major' in max && 'minor' in max && 'patch' in max);
+ assert('major' in min && 'minor' in min && 'patch' in min);
+ });
+ it('covers abstract methods', async function () {
+ await Promise.all(interfaceMethods(Abstract)
+ .map(async (method) => {
+ await assert.rejects(async () => db[method](), NotImplemented); // eslint-disable-line security/detect-object-injection
+ }));
+ });
+ }); // Interface
+
+}); // Abstract
\ No newline at end of file
'transaction',
'getAuthById',
'upsertAuth',
- 'insertLink',
+ 'upsertLink',
'getLinkById',
'getLinkByUrl',
'accessLink',
'expireLink',
- 'updateLink',
'getAllLinks',
];
const invokedMethods = methods.map(async (m) => {
--- /dev/null
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const DB = require('../../../src/db');
+const { Errors: { UnsupportedEngine } } = require('@squeep/db-helper');
+const DatabasePostgres = require('../../../src/db/postgresql');
+const DatabaseSQLite = require('../../../src/db/sqlite');
+const { StubLogger } = require('@squeep/test-helper');
+
+describe('DatabaseFactory', function () {
+ let db, logger, options;
+ beforeEach(function () {
+ logger = new StubLogger(sinon);
+ options = {
+ db: {
+ connectionString: '',
+ },
+ };
+ });
+ afterEach(function () {
+ sinon.restore();
+ db = undefined;
+ });
+ it('instantiates postgres db', function () {
+ options.db.connectionString = 'postgresql://blah',
+ db = new DB(logger, options);
+ assert(db instanceof DatabasePostgres);
+ });
+ it('instantiates sqlite db', function () {
+ options.db.connectionString = 'sqlite://:memory:',
+ db = new DB(logger, options);
+ assert(db instanceof DatabaseSQLite);
+ });
+ it('fails on unknown db', function () {
+ assert.throws(() => new DB(logger, options), UnsupportedEngine);
+ });
+
+}); // DatabaseFactory%
\ No newline at end of file
+++ /dev/null
-'use strict';
-
-const assert = require('node:assert');
-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
--- /dev/null
+'use strict';
+
+const assert = require('node:assert');
+const { step } = require('mocha-steps');
+const sinon = require('sinon');
+const { StubLogger } = require('@squeep/test-helper');
+
+describe('Database Integration', function () {
+ const implementations = [];
+
+ if (!process.env.INTEGRATION_TESTS) {
+ it.skip('integration tests not requested');
+ return;
+ }
+
+ if (process.env.POSTGRES_TEST_PATH) {
+ implementations.push({
+ name: 'PostgreSQL',
+ module: '../../../src/db/postgresql',
+ config: {
+ db: {
+ connectionString: `postgresql://${process.env.POSTGRES_TEST_PATH}`,
+ queryLogLevel: 'debug',
+ noWarnings: true,
+ },
+ },
+ });
+ }
+
+ if (process.env.SQLITE_TEST_PATH) {
+ implementations.push({
+ name: 'SQLite',
+ module: '../../../src/db/sqlite',
+ config: {
+ db: {
+ connectionString: `sqlite://${process.env.SQLITE_TEST_PATH}`,
+ queryLogLevel: 'debug',
+ sqliteOptimizeAfterChanges: 10,
+ },
+ },
+ });
+ }
+
+ if (!implementations.length) {
+ it('has specified implementations to test', function () {
+ assert.fail('No implementations have been configured for requested intergation tests.');
+ });
+ return;
+ }
+
+ for (const i of implementations) {
+ describe(i.name, function () {
+ let logger, DB, db;
+
+ before(async function () {
+ this.timeout(10 * 1000); // Allow some time for creating tables et cetera.
+ DB = require(i.module); // eslint-disable-line security/detect-non-literal-require
+ logger = new StubLogger(sinon);
+ db = new DB(logger, i.config);
+ await db.initialize();
+ await db._purgeTables(true);
+ });
+
+ after(async function () {
+ await db._closeConnection();
+ });
+
+ describe('healthCheck', function () {
+ it('succeeds', async function () {
+ const result = await db.healthCheck();
+ assert(result);
+ });
+ }); // Healthcheck
+
+ describe('Auth', function () {
+ let id, secret, credential;
+ beforeEach(function () {
+ id = 'username';
+ secret = 'Setec Astronomy';
+ credential = '$plain$hunter2';
+ });
+ step('no auth', async function () {
+ await db.context(async (dbCtx) => {
+ const auth = await db.getAuthById(dbCtx, id);
+ assert.equal(auth, undefined); // null or undefined
+ }); // dbCtx
+ });
+ step('add new auth', async function () {
+ credential='abc123';
+ await db.context(async (dbCtx) => {
+ await db.upsertAuth(dbCtx, id, secret, credential);
+
+ const auth = await db.getAuthById(dbCtx, id);
+ assert.strictEqual(auth.id, id);
+ assert.strictEqual(auth.secret, secret);
+ assert.strictEqual(auth.password, credential);
+ });
+ });
+ step('update existing auth', async function () {
+ await db.context(async (dbCtx) => {
+ await db.upsertAuth(dbCtx, id, secret, credential);
+
+ const auth = await db.getAuthById(dbCtx, id);
+ assert.strictEqual(auth.id, id);
+ assert.strictEqual(auth.secret, secret);
+ assert.strictEqual(auth.password, credential);
+ });
+ });
+ }); // Auth
+
+ describe('Link', function () {
+ let id, url, authToken;
+ beforeEach(function () {
+ id = 'short';
+ url = 'https://example.com/colugo';
+ authToken = 'token';
+ });
+ step('no link by id', async function () {
+ await db.context(async (dbCtx) => {
+ const link = await db.getLinkById(dbCtx, id);
+ assert.equal(link, undefined); // null or undefined
+ }); // dbCtx
+ });
+ step('no link by url', async function () {
+ await db.context(async (dbCtx) => {
+ const link = await db.getLinkByUrl(dbCtx, url);
+ assert.equal(link, undefined); // null or undefined
+ }); // dbCtx
+ });
+ step('no links at all', async function () {
+ await db.context(async (dbCtx) => {
+ const links = await db.getAllLinks(dbCtx);
+ assert.deepStrictEqual(links, []);
+ }); // dbCtx
+ });
+ step('insert link', async function () {
+ await db.context(async (dbCtx) => {
+ await db.upsertLink(dbCtx, id, url, authToken);
+
+ const link = await db.getLinkByUrl(dbCtx, url);
+ assert.strictEqual(link.id, id);
+ assert.strictEqual(link.url, url);
+ assert.strictEqual(link.authToken, authToken);
+ assert(link.created instanceof Date);
+ assert.strictEqual(link.lastAccess, -Infinity);
+ assert.strictEqual(link.expires, null);
+ }); // dbCtx
+ });
+ step('update link', async function () {
+ url = 'https://example.com/tayra';
+ await db.context(async (dbCtx) => {
+ await db.upsertLink(dbCtx, id, url, authToken);
+
+ const link = await db.getLinkByUrl(dbCtx, url);
+ assert.strictEqual(link.id, id);
+ assert.strictEqual(link.url, url);
+ assert.strictEqual(link.authToken, authToken);
+ assert(link.created instanceof Date);
+ assert.strictEqual(link.lastAccess, -Infinity);
+ assert.strictEqual(link.expires, null);
+ }); // dbCtx
+ });
+ step('access link', async function () {
+ await db.context(async (dbCtx) => {
+ const accessedLink = await db.accessLink(dbCtx, id);
+ assert(accessedLink);
+
+ const link = await db.getLinkById(dbCtx, id);
+ assert.strictEqual(link.accesses, 1);
+ assert(link.lastAccess instanceof Date);
+ }); // dbCtx
+ });
+ step('expire link', async function () {
+ const when = new Date();
+ await db.context(async (dbCtx) => {
+ await db.expireLink(dbCtx, id, when);
+
+ const link = await db.getLinkById(dbCtx, id);
+ assert(link.expires instanceof Date);
+ }); // dbCtx
+ });
+ step('all links', async function () {
+ await db.context(async (dbCtx) => {
+ const links = await db.getAllLinks(dbCtx);
+ assert.strictEqual(links.length, 1);
+ }); // dbCtx
+ });
+ }); // Link
+
+ }); // i.name
+ } // for i
+}); // Database Integration
\ No newline at end of file
--- /dev/null
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const { StubLogger } = require('@squeep/test-helper');
+const {
+ test: {
+ stubPgp,
+ interfaceMethods,
+ itChecksImplementation,
+ },
+ Errors: {
+ UnexpectedResult,
+ },
+} = require('@squeep/db-helper');
+const Abstract = require('../../../src/db/abstract');
+const expectedExeption = new Error('oh no');
+
+describe('DatabasePostgreSQL', function () {
+ const DB = require('../../../src/db/postgresql');
+ let db, logger, options, dbCtx;
+
+ beforeEach(function () {
+ logger = new StubLogger(sinon);
+ options = {
+ db: {
+ connectionString: 'postgresql://blah',
+ },
+ };
+ db = new DB(logger, options, stubPgp);
+ dbCtx = db.db;
+ });
+
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ describe('Implementation', function () {
+ it('implements interface', async function () {
+ await itChecksImplementation(db, interfaceMethods(Abstract));
+ });
+ }); // Implementation
+
+ describe('schemaVersionsSupported', function () {
+ it('is implemented', function () {
+ const result = db.schemaVersionsSupported;
+ assert('min' in result);
+ assert('max' in result);
+ });
+ }); // schemaVersionsSupported
+
+ describe('getAuthById', function () {
+ let id;
+ beforeEach(function () {
+ sinon.stub(db.db, 'oneOrNone');
+ id = 'id';
+ });
+ it('stubbed success', async function () {
+ const expected = {
+ id: 'id',
+ secret: 'secret',
+ password: 'password',
+ };
+ db.db.oneOrNone.resolves(expected);
+ const result = await db.getAuthById(dbCtx, id);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.db.oneOrNone.rejects(expectedExeption);
+ await assert.rejects(async () => db.getAuthById(dbCtx, id), expectedExeption);
+ });
+ }); // getAuthById
+
+ describe('upsertAuth', function () {
+ let id, secret, credential;
+ beforeEach(function () {
+ sinon.stub(db.db, 'result');
+ id = 'id';
+ secret = 'secret';
+ credential = 'credential';
+ });
+ it('stubbed success', async function () {
+ db.db.result.resolves({ rowCount: 1 });
+ await db.upsertAuth(dbCtx, id, secret, credential);
+ });
+ it('stubbed failure', async function () {
+ db.db.result.resolves({ rowCount: 0 });
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), UnexpectedResult);
+ });
+ it('stubbed failure', async function () {
+ db.db.result.rejects(expectedExeption);
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedExeption);
+ });
+ }); // upsertAuth
+
+ describe('upsertLink', function () {
+ let id, url, authToken;
+ beforeEach(function () {
+ sinon.stub(db.db, 'result');
+ id = 'id';
+ url = 'https://example.com/tully';
+ authToken = 'token';
+ });
+ it('stubbed success', async function () {
+ db.db.result.resolves({ rowCount: 1 });
+ await db.upsertLink(dbCtx, id, url, authToken);
+ });
+ it('stubbed failure', async function () {
+ db.db.result.rejects(expectedExeption);
+ await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedExeption);
+ });
+ }); // upsertLink
+
+ describe('getLinkById', function () {
+ let id;
+ beforeEach(function () {
+ sinon.stub(db.db, 'oneOrNone');
+ id = 'id';
+ });
+ it('stubbed success', async function () {
+ const dbResponse = {
+ id: 'id',
+ url: 'url',
+ created: 0,
+ expires: 0,
+ lastAccess: 0,
+ };
+ db.db.oneOrNone.resolves(dbResponse);
+ const result = await db.getLinkById(dbCtx, id);
+ assert.deepStrictEqual(result, dbResponse);
+ });
+ it('stubbed failure', async function () {
+ db.db.oneOrNone.rejects(expectedExeption);
+ await assert.rejects(async () => db.getLinkById(dbCtx, id), expectedExeption);
+ });
+ }); // getLinkById
+
+ describe('getLinkByUrl', function () {
+ let url;
+ beforeEach(function () {
+ sinon.stub(db.db, 'oneOrNone');
+ url = 'https://example.com/rah';
+ });
+ it('stubbed success', async function () {
+ const dbResponse = {
+ id: 'id',
+ url: 'url',
+ created: 0,
+ expires: 0,
+ lastAccess: 0,
+ };
+ db.db.oneOrNone.resolves(dbResponse);
+ const result = await db.getLinkByUrl(dbCtx, url);
+ assert.deepStrictEqual(result, dbResponse);
+ });
+ it('stubbed failure', async function () {
+ db.db.oneOrNone.rejects(expectedExeption);
+ await assert.rejects(async () => db.getLinkByUrl(dbCtx, url), expectedExeption);
+ });
+ }); // getLinkByUrl
+
+ describe('accessLink', function () {
+ let id;
+ beforeEach(function () {
+ sinon.stub(db.db, 'oneOrNone');
+ id = 'id';
+ });
+ it('stubbed success', async function () {
+ const expected = {
+ id: 'id',
+ url: 'url',
+ created: 0,
+ expires: 0,
+ lastAccess: 0,
+ };
+ db.db.oneOrNone.resolves(expected);
+ const result = await db.accessLink(dbCtx, id);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.db.oneOrNone.rejects(expectedExeption);
+ await assert.rejects(async () => db.accessLink(dbCtx, id), expectedExeption);
+ });
+ }); // accessLink
+
+ describe('expireLink', function () {
+ let id, expires;
+ beforeEach(function () {
+ sinon.stub(db.db, 'result');
+ id = 'id';
+ expires = new Date();
+ });
+ it('stubbed success', async function () {
+ const returns = {
+ rowCount: 1,
+ rows: [ { id: 1 } ],
+ duration: 10,
+ };
+ db.db.result.resolves(returns);
+ await db.expireLink(dbCtx, id, expires);
+ });
+ it('stubbed failure', async function () {
+ db.db.result.rejects(expectedExeption);
+ await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedExeption);
+ });
+ it('stubbed failure', async function () {
+ db.db.result.resolves({ rowCount: 0 });
+ await assert.rejects(async () => db.expireLink(dbCtx, id, expires), UnexpectedResult);
+ });
+ }); // expireLink
+
+ describe('getAllLinks', function () {
+ beforeEach(function () {
+ sinon.stub(db.db, 'manyOrNone');
+ });
+ it('stubbed success', async function () {
+ const expected = [
+ {
+ id: 'id',
+ url: 'url',
+ created: new Date(0),
+ expires: null,
+ lastAccess: new Date(1),
+ },
+ ];
+ db.db.manyOrNone.resolves(expected);
+ const result = await db.getAllLinks(dbCtx);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.db.manyOrNone.rejects(expectedExeption);
+ await assert.rejects(async () => db.getAllLinks(dbCtx), expectedExeption);
+ });
+ }); // getAllLinks
+
+});
\ No newline at end of file
const noExpectedException = 'did not get expected exception';
describe('PostgresDatabase', function () {
- const PostgresDatabase = require('../../../../src/db/postgres');
+ const PostgresDatabase = require('../../../../src/db/postgres-old');
let pgpStub, db, logger, options, dbCtx;
beforeEach(function () {
});
}); // _linkToNative
- describe('insertLink', function () {
+ describe('upsertLink', function () {
let id, url, authToken;
it('stubbed success', async function () {
const returns = {
duration: 0,
lastInsertRowid: undefined,
};
- const result = await db.insertLink(dbCtx, id, url, authToken);
+ const result = await db.upsertLink(dbCtx, id, url, authToken);
assert.deepStrictEqual(result, expected);
});
it('stubbed failure', async function () {
id = 'id';
db.db.result.rejects(expectedExeption);
try {
- await db.insertLink(dbCtx, id, url, authToken);
+ await db.upsertLink(dbCtx, id, url, authToken);
assert.fail(noExpectedException);
} catch (e) {
assert.deepStrictEqual(e, expectedExeption, noExpectedException);
}
});
- }); // insertLink
+ }); // upsertLink
describe('getLinkById', function () {
let id;
});
}); // 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 = [
--- /dev/null
+'use strict';
+
+const assert = require('node:assert');
+const sinon = require('sinon');
+const { StubLogger } = require('@squeep/test-helper');
+const {
+ test: {
+ interfaceMethods,
+ itChecksImplementation,
+ },
+ Errors: {
+ UnexpectedResult,
+ },
+} = require('@squeep/db-helper');
+const Abstract = require('../../../src/db/abstract');
+
+const expectedException = new Error('oh no');
+
+describe('DatabaseSQLite', function () {
+ const DB = require('../../../src/db/sqlite');
+ let db, logger, options, dbCtx;
+
+ beforeEach(async function () {
+ logger = new StubLogger(sinon);
+ options = {
+ db: {},
+ };
+ db = new DB(logger, options);
+ await db.initialize();
+ dbCtx = undefined;
+ });
+
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ describe('Implementation', function () {
+ it('implements interface', async function () {
+ await itChecksImplementation(db, interfaceMethods(Abstract));
+ });
+ }); // Implementation
+
+ describe('schemaVersionsSupported', function () {
+ it('is implemented', function () {
+ const result = db.schemaVersionsSupported;
+ assert('min' in result);
+ assert('max' in result);
+ });
+ }); // schemaVersionsSupported
+
+ describe('getAuthById', function () {
+ let id;
+ beforeEach(function () {
+ sinon.stub(db.statement.authGetById, 'get');
+ id = 'id';
+ });
+
+ it('stubbed success', async function () {
+ const expected = {
+ id: 'id',
+ secret: 'secret',
+ password: 'password',
+ };
+ db.statement.authGetById.get.returns(expected);
+ const result = await db.getAuthById(dbCtx, id);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.statement.authGetById.get.throws(expectedException);
+ await assert.rejects(async () => db.getAuthById(dbCtx, id), expectedException);
+ });
+ }); // getAuthById
+
+ describe('upsertAuth', function () {
+ let id, secret, credential;
+ beforeEach(function () {
+ sinon.stub(db.statement.authInsert, 'run').returns({ changes: 1n, lastInsertRowid: 123n });
+ sinon.stub(db.statement.authUpdate, 'run').returns({ changes: 1n, lastInsertRowid: 123n });
+ id = 'id';
+ secret = 'secert';
+ credential = 'credential';
+ });
+ it('stubbed insert success', async function () {
+ await db.upsertAuth(dbCtx, id, secret, credential);
+ });
+ it('stubbed update success', async function () {
+ db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ await db.upsertAuth(dbCtx, id, secret, credential);
+ });
+ it('covers insert error', async function () {
+ db.statement.authInsert.run.throws(expectedException);
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException);
+ });
+ it('covers unexpected insert error', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ changes: 0n,
+ lastInsertRowid: undefined,
+ };
+ db.statement.authInsert.run.returns(returns);
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException);
+ });
+ it('covers update error', async function () {
+ db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.authUpdate.run.throws(expectedException);
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException);
+ });
+ it('covers unexpected update error', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ changes: 0n,
+ lastInsertRowid: undefined,
+ };
+ db.statement.authInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.authUpdate.run.returns(returns);
+ await assert.rejects(async () => db.upsertAuth(dbCtx, id, secret, credential), expectedException);
+ });
+ }); // upsertAuth
+
+ describe('upsertLink', function () {
+ let id, url, authToken;
+ beforeEach(function () {
+ sinon.stub(db.statement.linkInsert, 'run');
+ sinon.stub(db.statement.linkUpdate, 'run');
+ id = 'id';
+ url = 'https://example.com/fluke';
+ authToken = 'token';
+ });
+
+ it('stubbed insert success', async function () {
+ const info = {
+ changes: 1n,
+ lastInsertRowid: 123n,
+ };
+ db.statement.linkInsert.run.returns(info);
+ await db.upsertLink(dbCtx, id, url, authToken);
+ });
+ it('stubbed update success', async function () {
+ const info = {
+ changes: 1n,
+ lastInsertRowid: 123n,
+ };
+ db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.linkUpdate.run.returns(info);
+ await db.upsertLink(dbCtx, id, url, authToken);
+ });
+ it('stubbed insert failure', async function () {
+ db.statement.linkInsert.run.throws(expectedException);
+ await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException);
+ });
+ it('stubbed unexpected insert failure', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ changes: 0n,
+ lastInsertRowid: undefined,
+ };
+ id = 'id';
+ db.statement.linkInsert.run.returns(returns);
+ await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException);
+ });
+ it('stubbed updatefailure', async function () {
+ db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.linkUpdate.run.throws(expectedException);
+ await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException);
+ });
+ it('stubbed unexpected update failure', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ changes: 0n,
+ lastInsertRowid: undefined,
+ };
+ id = 'id';
+ db.statement.linkInsert.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.linkUpdate.run.returns(returns);
+ await assert.rejects(async () => db.upsertLink(dbCtx, id, url, authToken), expectedException);
+ });
+ }); // upsertLink
+
+ describe('getLinkById', function () {
+ let id;
+
+ beforeEach(function () {
+ sinon.stub(db.statement.linkGetById, 'get');
+ id = 'id';
+ });
+
+ it('stubbed success', async function () {
+ const returns = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: 0,
+ expires: 2,
+ authToken: 'abc',
+ lastAccess: 1,
+ accesses: 0,
+ };
+ const expected = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: new Date(0),
+ expires: new Date(2 * 1000),
+ authToken: 'abc',
+ lastAccess: new Date(1 * 1000),
+ accesses: 0,
+ };
+ id = 'id';
+ db.statement.linkGetById.get.returns(returns);
+ const result = await db.getLinkById(dbCtx, id);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.statement.linkGetById.get.throws(expectedException);
+ await assert.rejects(async () => db.getLinkById(dbCtx, id), expectedException);
+ });
+ }); // getLinkById
+
+ describe('getLinkByUrl', function () {
+ let url;
+
+ beforeEach(function () {
+ sinon.stub(db.statement.linkGetByUrl, 'get');
+ url = 'https://example.com/tayra';
+ });
+
+ it('stubbed success', async function () {
+ const returns = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: 10,
+ expires: 123,
+ authToken: 'abc',
+ lastAccess: 0,
+ accesses: 0,
+ };
+ const expected = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: new Date(10 * 1000),
+ expires: new Date(123 * 1000),
+ authToken: 'abc',
+ lastAccess: -Infinity,
+ accesses: 0,
+ };
+ db.statement.linkGetByUrl.get.returns(returns);
+ const result = await db.getLinkByUrl(dbCtx, url);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ url = 'url';
+ db.statement.linkGetByUrl.get.throws(expectedException);
+ await assert.rejects(async () => db.getLinkByUrl(dbCtx, url), expectedException);
+ });
+ }); // getLinkByUrl
+
+ describe('accessLink', function () {
+ let id;
+
+ beforeEach(function () {
+ sinon.stub(db.statement.linkGetById, 'get');
+ sinon.stub(db.statement.linkAccess, 'run');
+ id = 'id';
+ });
+
+ it('stubbed exists success', async function () {
+ const returns = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: 0,
+ expires: 0,
+ authToken: 'abc',
+ lastAccess: 0,
+ accesses: 0,
+ };
+ const expected = {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: new Date(0),
+ expires: new Date(0),
+ authToken: 'abc',
+ lastAccess: -Infinity,
+ accesses: 0,
+ };
+ db.statement.linkGetById.get.returns(returns);
+ db.statement.linkAccess.run.returns({ changes: 1n });
+ 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.linkGetById.get.returns(returns);
+ db.statement.linkAccess.run.returns({ changes: 0 });
+ const result = await db.accessLink(dbCtx, id);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed increment failure', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ id: 'id',
+ url: 'url',
+ created: 0,
+ expires: 0,
+ authToken: 'abc',
+ lastAccess: 0,
+ accesses: 0,
+ };
+ db.statement.linkGetById.get.returns(returns);
+ db.statement.linkAccess.run.returns({ changes: 0 });
+ await assert.rejects(async () => db.accessLink(dbCtx, id), expectedException);
+ });
+ it('stubbed failure', async function () {
+ db.statement.linkGetById.get.throws(expectedException);
+ await assert.rejects(async () => db.accessLink(dbCtx, id), expectedException);
+ });
+ }); // accessLink
+
+ describe('expireLink', function () {
+ let id, expires;
+
+ beforeEach(function () {
+ sinon.stub(db.statement.linkExpire, 'run');
+ id = 'id';
+ expires = new Date();
+ });
+
+ it('stubbed success', async function () {
+ const returns = {
+ changes: 1n,
+ lastInsertRowid: 123n,
+ };
+ db.statement.linkExpire.run.returns(returns);
+ await db.expireLink(dbCtx, id, expires);
+ });
+ it('stubbed change failure', async function () {
+ const expectedException = UnexpectedResult;
+ const returns = {
+ changes: 0n,
+ lastInsertRowid: undefined,
+ };
+ db.statement.linkExpire.run.returns(returns);
+ await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedException);
+ });
+ it('stubbed failure', async function () {
+ db.statement.linkExpire.run.throws(expectedException);
+ await assert.rejects(async () => db.expireLink(dbCtx, id, expires), expectedException);
+ });
+ }); // expireLink
+
+ describe('getAllLinks', function () {
+ beforeEach(function () {
+ sinon.stub(db.statement.linkGetAll, 'all');
+ });
+
+ it('stubbed success', async function () {
+ const returns = [
+ {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: 0,
+ expires: null,
+ authToken: 'abc',
+ lastAccess: 0,
+ accesses: 0,
+ },
+ ];
+ const expected = [
+ {
+ id: 'id',
+ isSpecial: false,
+ url: 'url',
+ created: new Date(0),
+ expires: null,
+ authToken: 'abc',
+ lastAccess: -Infinity,
+ accesses: 0,
+ },
+ ];
+ db.statement.linkGetAll.all.returns(returns);
+ const result = await db.getAllLinks(dbCtx);
+ assert.deepStrictEqual(result, expected);
+ });
+ it('stubbed failure', async function () {
+ db.statement.linkGetAll.all.throws(expectedException);
+ await assert.rejects(async () => db.getAllLinks(dbCtx), expectedException);
+ });
+ }); // getAllLinks
+
+});
\ No newline at end of file
const noExpectedException = 'did not get expected exception';
describe('SQLiteDatabase', function () {
- const SQLiteDatabase = require('../../../../src/db/sqlite');
+ const SQLiteDatabase = require('../../../../src/db/sqlite-old');
let db, logger, options, dbCtx;
beforeEach(function () {
});
}); // upsertAuth
- describe('insertLink', function () {
+ describe('upsertLink', function () {
let id, url, authToken;
beforeEach(function () {
- sinon.stub(db.statement.insertLink, 'run');
+ sinon.stub(db.statement.upsertLink, 'run');
sinon.stub(db.statement.updateLink, 'run');
});
lastInsertRowid: BigInt(123),
};
id = 'id';
- db.statement.insertLink.run.returns(info);
+ db.statement.upsertLink.run.returns(info);
const expected = {
changes: 1,
lastInsertRowid: 123,
};
- const result = await db.insertLink(dbCtx, id, url, authToken);
+ const result = await db.upsertLink(dbCtx, id, url, authToken);
assert.deepStrictEqual(result, expected);
});
it('stubbed update success', async function () {
lastInsertRowid: BigInt(123),
};
id = 'id';
- db.statement.insertLink.run.throws({ code: 'SQLITE_CONSTRAINT_UNIQUE' });
+ db.statement.upsertLink.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);
+ const result = await db.upsertLink(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);
+ db.statement.upsertLink.run.throws(expectedExeption);
try {
- await db.insertLink(dbCtx, id, url, authToken);
+ await db.upsertLink(dbCtx, id, url, authToken);
assert.fail(noExpectedException);
} catch (e) {
assert.deepStrictEqual(e, expectedExeption, noExpectedException);
lastInsertRowid: undefined,
};
id = 'id';
- db.statement.insertLink.run.returns(returns);
+ db.statement.upsertLink.run.returns(returns);
try {
- await db.insertLink(dbCtx, id, url, authToken);
+ await db.upsertLink(dbCtx, id, url, authToken);
assert.fail(noExpectedException);
} catch (e) {
assert(e instanceof expectedException);
}
});
- }); // insertLink
+ }); // upsertLink
describe('getLinkById', function () {
let id;
});
}); // 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');
accessLink: sinon.stub(),
getLinkByUrl: sinon.stub(),
expireLink: sinon.stub(),
- insertLink: sinon.stub(),
+ upsertLink: sinon.stub(),
getAllLinks: sinon.stub(),
};
options = {};
describe('_validateContextURL', function () {
it('allows admin to create local static link', function () {
- ctx.sourceLink = `${manager.staticDirectory}file.txt`;
+ ctx.sourceLink = `${manager.staticRoutePrefix}file.txt`;
ctx.authenticationId = 'awoo';
manager._validateContextURL(ctx);
});
it('handles local static link', async function () {
const file = 'foop.txt';
link = {
- url: `${manager.staticDirectory}${file}`,
+ url: `${manager.staticRoutePrefix}${file}`,
};
manager.db.accessLink.resolves(link);
try {
it('creates a link', async function () {
ctx.parsedBody.url = 'http://example.com/insert';
await manager.postRoot(res, ctx);
- assert(manager.db.insertLink.called);
+ assert(manager.db.upsertLink.called);
assert(res.end.called);
});
it('returns existing link', async function () {
};
manager.db.getLinkByUrl.resolves(existingLink);
await manager.postRoot(res, ctx);
- assert(!manager.db.insertLink.called);
+ assert(!manager.db.upsertLink.called);
assert(!manager.db.expireLink.called);
assert(res.end.called);
});
};
manager.db.getLinkByUrl.resolves(existingLink);
await manager.postRoot(res, ctx);
- assert(!manager.db.insertLink.called);
+ assert(!manager.db.upsertLink.called);
assert(manager.db.expireLink.called);
assert(res.end.called);
});
};
manager.db.getLinkById.resolves(existingLink);
await manager.putById(res, ctx);
- assert(manager.db.insertLink.called);
+ assert(manager.db.upsertLink.called);
assert(res.end.called);
});
it('does not create without admin', async function () {
ctx.parsedBody.url = url;
ctx.authenticationId = 'blah';
await manager.putById(res, ctx);
- assert(manager.db.insertLink.called);
+ assert(manager.db.upsertLink.called);
assert.strictEqual(res.statusCode, 201);
assert(res.end.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);
--- /dev/null
+'use strict';
+
+const { test: { stubCreator } } = require('@squeep/db-helper');
+const Abstract = require('../src/db/abstract');
+
+module.exports = stubCreator(Abstract);
\ No newline at end of file