From 7fc0103eec6d8ec7b83e5993437664633bbf675f Mon Sep 17 00:00:00 2001 From: William Killerud Date: Fri, 16 Aug 2024 13:18:50 +0200 Subject: [PATCH] refactor: inline the test sink We're the only ones using it --- package.json | 8 +- test/400.test.js | 2 +- test/404.test.js | 2 +- test/alias.map.test.js | 2 +- test/alias.npm.test.js | 2 +- test/alias.pkg.test.js | 2 +- test/auth.test.js | 2 +- test/http.cache.control.test.js | 2 +- test/http.etag.test.js | 2 +- test/http.override.cache.control.test.js | 2 +- test/http.query.params.test.js | 2 +- test/img.test.js | 2 +- test/map.test.js | 2 +- test/npm.test.js | 2 +- test/pkg-put-write-integrity.test.js | 2 +- test/pkg.test.js | 2 +- test/utils/mem-entry.js | 35 +++ test/utils/sink.js | 317 +++++++++++++++++++++++ 18 files changed, 372 insertions(+), 18 deletions(-) create mode 100644 test/utils/mem-entry.js create mode 100644 test/utils/sink.js diff --git a/package.json b/package.json index 84062e3..597045c 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "lint": "eslint .", "lint:fix": "eslint --fix .", "start": "node ./bin/eik-server.js | pino-pretty", - "test": "cross-env LOG_LEVEL=fatal tap ./test --disable-coverage --allow-empty-coverage", - "test:ci": "cross-env LOG_LEVEL=trace tap ./test --disable-coverage --allow-empty-coverage", - "test:snapshots": "cross-env LOG_LEVEL=fatal tap --snapshot --disable-coverage --allow-empty-coverage", + "test": "cross-env LOG_LEVEL=fatal npm run test:tap", + "test:ci": "cross-env LOG_LEVEL=trace npm run test:tap", + "test:snapshots": "cross-env LOG_LEVEL=fatal npm run test:tap -- --snapshot", + "test:tap": "tap ./test/**/*.test.js --disable-coverage --allow-empty-coverage", "types": "run-s types:module types:test", "types:module": "tsc", "types:test": "tsc --project tsconfig.test.json" @@ -58,6 +59,7 @@ "@eik/prettier-config": "1.0.1", "@eik/semantic-release-config": "1.0.0", "@eik/typescript-config": "1.0.0", + "@types/mime": "3.0.4", "cross-env": "7.0.3", "eslint": "9.8.0", "form-data": "4.0.0", diff --git a/test/400.test.js b/test/400.test.js index 36a2f19..aaea61b 100644 --- a/test/400.test.js +++ b/test/400.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/404.test.js b/test/404.test.js index 49ae562..47623e6 100644 --- a/test/404.test.js +++ b/test/404.test.js @@ -3,7 +3,7 @@ import fastify from 'fastify'; import fetch from 'node-fetch'; import tap from 'tap'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; /** @type {import('fastify').FastifyInstance} */ diff --git a/test/alias.map.test.js b/test/alias.map.test.js index 3183a06..2d525f3 100644 --- a/test/alias.map.test.js +++ b/test/alias.map.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/alias.npm.test.js b/test/alias.npm.test.js index 109683d..eada7ac 100644 --- a/test/alias.npm.test.js +++ b/test/alias.npm.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/alias.pkg.test.js b/test/alias.pkg.test.js index 59e506b..4d4165e 100644 --- a/test/alias.pkg.test.js +++ b/test/alias.pkg.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/auth.test.js b/test/auth.test.js index ff070f3..271725a 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -3,7 +3,7 @@ import fastify from 'fastify'; import fetch from 'node-fetch'; import tap from 'tap'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; /** @type {import('fastify').FastifyInstance} */ diff --git a/test/http.cache.control.test.js b/test/http.cache.control.test.js index fb30369..06ca722 100644 --- a/test/http.cache.control.test.js +++ b/test/http.cache.control.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/http.etag.test.js b/test/http.etag.test.js index 697c226..7604d08 100644 --- a/test/http.etag.test.js +++ b/test/http.etag.test.js @@ -2,7 +2,7 @@ import fastify from 'fastify'; import fetch from 'node-fetch'; import tap from 'tap'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; /** @type {import('fastify').FastifyInstance} */ diff --git a/test/http.override.cache.control.test.js b/test/http.override.cache.control.test.js index 76ccb0a..aa5a7f6 100644 --- a/test/http.override.cache.control.test.js +++ b/test/http.override.cache.control.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/http.query.params.test.js b/test/http.query.params.test.js index f8554d4..b4a48cc 100644 --- a/test/http.query.params.test.js +++ b/test/http.query.params.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/img.test.js b/test/img.test.js index dabcd69..d68f1d7 100644 --- a/test/img.test.js +++ b/test/img.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/map.test.js b/test/map.test.js index 4d131a2..b8a4e4d 100644 --- a/test/map.test.js +++ b/test/map.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/npm.test.js b/test/npm.test.js index 0a95fca..229c1ef 100644 --- a/test/npm.test.js +++ b/test/npm.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/pkg-put-write-integrity.test.js b/test/pkg-put-write-integrity.test.js index c4b9f23..46768c9 100644 --- a/test/pkg-put-write-integrity.test.js +++ b/test/pkg-put-write-integrity.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/pkg.test.js b/test/pkg.test.js index e37cba7..f400c9b 100644 --- a/test/pkg.test.js +++ b/test/pkg.test.js @@ -6,7 +6,7 @@ import tap from 'tap'; import url from 'url'; import fs from 'fs'; -import Sink from '@eik/core/lib/sinks/test.js'; +import Sink from './utils/sink.js'; import Server from '../lib/main.js'; const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/test/utils/mem-entry.js b/test/utils/mem-entry.js new file mode 100644 index 0000000..b378566 --- /dev/null +++ b/test/utils/mem-entry.js @@ -0,0 +1,35 @@ +import crypto from 'node:crypto'; + +const Entry = class Entry { + constructor({ mimeType = 'application/octet-stream', payload = [] } = {}) { + this._mimeType = mimeType; + this._payload = payload; + this._hash = ''; + + if (Array.isArray(payload)) { + const hash = crypto.createHash('sha512'); + payload.forEach((buffer) => { + hash.update(buffer.toString()); + }); + this._hash = `sha512-${hash.digest('base64')}`; + } + } + + get mimeType() { + return this._mimeType; + } + + get payload() { + return this._payload; + } + + get hash() { + return this._hash; + } + + get [Symbol.toStringTag]() { + return 'Entry'; + } +}; + +export default Entry; diff --git a/test/utils/sink.js b/test/utils/sink.js new file mode 100644 index 0000000..0848268 --- /dev/null +++ b/test/utils/sink.js @@ -0,0 +1,317 @@ +import { Writable, Readable } from 'node:stream'; +import { ReadFile } from '@eik/common'; +import Metrics from '@metrics/client'; +import Sink from '@eik/sink'; +import mime from 'mime'; +import path from 'node:path'; +import Entry from './mem-entry.js'; + +function toUrlPathname(pathname) { + return pathname.replace(/\\/g, '/'); +} + +const DEFAULT_ROOT_PATH = '/eik'; + +const counterMetric = { + name: 'eik_core_sink_test', + description: 'Counter measuring access to the in memory test storage sink', + labels: { + operation: 'n/a', + success: false, + access: false, + }, +}; + +/** + * @deprecated Use eik/sink-memory or implement your own. This class will be removed in a future version of core. + */ +export default class SinkTest extends Sink { + constructor({ rootPath = DEFAULT_ROOT_PATH } = {}) { + super(); + this._rootPath = rootPath; + this._metrics = new Metrics(); + this._state = new Map(); + + this._counter = this._metrics.counter(counterMetric); + + // eslint-disable-next-line no-unused-vars + this._writeDelayResolve = (a) => -1; + // eslint-disable-next-line no-unused-vars + this._writeDelayChunks = (a) => -1; + } + + get metrics() { + return this._metrics; + } + + set(filePath, payload) { + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + const mimeType = mime.getType(pathname) || 'application/octet-stream'; + + let entry; + + if (Array.isArray(payload)) { + entry = new Entry({ mimeType, payload }); + } else { + entry = new Entry({ mimeType, payload: [payload] }); + } + + this._state.set(pathname, entry); + } + + get(filePath) { + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + if (this._state.has(pathname)) { + const entry = this._state.get(pathname); + return entry.payload.join(''); + } + return null; + } + + dump() { + return Array.from(this._state.entries()); + } + + clear() { + this._state.clear(); + this._counter.removeAllListeners(); + this._metrics.destroy(); + + this._metrics = new Metrics(); + this._state = new Map(); + + this._counter = this._metrics.counter(counterMetric); + + // eslint-disable-next-line no-unused-vars + this._writeDelayResolve = (a) => -1; + // eslint-disable-next-line no-unused-vars + this._writeDelayChunks = (a) => -1; + } + + load(items) { + if (!Array.isArray(items)) { + throw new Error('Argument "items" must be an Array'); + } + this._state = new Map(items); + } + + /** + * @param {(count: number) => number} fn + */ + set writeDelayResolve(fn) { + if (typeof fn !== 'function') { + throw new TypeError('Value must be a function'); + } + this._writeDelayResolve = fn; + } + + /** + * @param {(count: number) => number} fn + */ + set writeDelayChunks(fn) { + if (typeof fn !== 'function') { + throw new TypeError('Value must be a function'); + } + this._writeDelayChunks = fn; + } + + // Common SINK API + + write(filePath, contentType) { + return new Promise((resolve, reject) => { + const operation = 'write'; + + try { + Sink.validateFilePath(filePath); + Sink.validateContentType(contentType); + } catch (error) { + this._counter.inc({ labels: { operation } }); + reject(error); + return; + } + + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + + if (pathname.indexOf(this._rootPath) !== 0) { + this._counter.inc({ labels: { operation } }); + reject(new Error(`Directory traversal - ${filePath}`)); + return; + } + + const chunkDelay = this._writeDelayChunks; + const payload = []; + let count = 0; + const stream = new Writable({ + write(chunk, encoding, cb) { + const timeout = chunkDelay(count); + count += 1; + + if (timeout < 0) { + payload.push(chunk); + cb(); + } else { + setTimeout(() => { + payload.push(chunk); + cb(); + }, timeout); + } + }, + }); + + stream.on('finish', () => { + const entry = new Entry({ + mimeType: contentType, + payload, + }); + + this._state.set(pathname, entry); + + this._counter.inc({ + labels: { + success: true, + access: true, + operation, + }, + }); + }); + + const resolveDelay = this._writeDelayResolve(); + if (resolveDelay < 0) { + resolve(stream); + } else { + setTimeout(() => { + resolve(stream); + }, resolveDelay); + } + }); + } + + read(filePath) { + return new Promise((resolve, reject) => { + const operation = 'read'; + + try { + Sink.validateFilePath(filePath); + } catch (error) { + this._counter.inc({ labels: { operation } }); + reject(error); + return; + } + + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + + if (pathname.indexOf(this._rootPath) !== 0) { + this._counter.inc({ labels: { operation } }); + reject(new Error(`Directory traversal - ${filePath}`)); + return; + } + + const entry = this._state.get(pathname); + const payload = entry.payload || []; + const file = new ReadFile({ + mimeType: entry.mimeType, + etag: entry.hash, + }); + + file.stream = new Readable({ + read() { + payload.forEach((item) => { + this.push(item); + }); + this.push(null); + }, + }); + + file.stream.on('end', () => { + this._counter.inc({ + labels: { + success: true, + access: true, + operation, + }, + }); + }); + + resolve(file); + }); + } + + delete(filePath) { + return new Promise((resolve, reject) => { + const operation = 'delete'; + + try { + Sink.validateFilePath(filePath); + } catch (error) { + this._counter.inc({ labels: { operation } }); + reject(error); + return; + } + + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + + if (pathname.indexOf(this._rootPath) !== 0) { + this._counter.inc({ labels: { operation } }); + reject(new Error(`Directory traversal - ${filePath}`)); + return; + } + + // Delete recursively + Array.from(this._state.keys()).forEach((key) => { + if (key.startsWith(pathname)) { + this._state.delete(key); + } + }); + + this._counter.inc({ + labels: { + success: true, + access: true, + operation, + }, + }); + + resolve(); + }); + } + + exist(filePath) { + return new Promise((resolve, reject) => { + const operation = 'exist'; + + try { + Sink.validateFilePath(filePath); + } catch (error) { + this._counter.inc({ labels: { operation } }); + reject(error); + return; + } + + const pathname = toUrlPathname(path.join(this._rootPath, filePath)); + + if (pathname.indexOf(this._rootPath) !== 0) { + this._counter.inc({ labels: { operation } }); + reject(new Error(`Directory traversal - ${filePath}`)); + return; + } + + this._counter.inc({ + labels: { + success: true, + access: true, + operation, + }, + }); + + if (this._state.has(pathname)) { + resolve(); + return; + } + reject(new Error('File does not exist')); + }); + } + + get [Symbol.toStringTag]() { + return 'SinkTest'; + } +}