From 58553f3e34e8718a0b94bd2aa28ec0f5cde63a1d Mon Sep 17 00:00:00 2001 From: Jonatan Nilsson Date: Thu, 6 Jan 2022 09:01:10 +0000 Subject: [PATCH] Many updates. Added ability to upload prefix-less files. Added ability to remove files. Breaking: Renamed name to filename when listing files in folder. Breaking: Fixed the schema response for /media/resize. It is now consistent with other upload methods. --- api/media/formidable.mjs | 32 +-- api/media/routes.mjs | 57 ++++-- api/media/security.mjs | 4 +- api/server.mjs | 4 +- test/helper.client.mjs | 5 + test/media/api.test.mjs | 242 ++++++++++++++++++++--- test/media/routes.test.mjs | 372 +++++++++++++++++++++++++---------- test/media/security.test.mjs | 32 +-- 8 files changed, 566 insertions(+), 182 deletions(-) diff --git a/api/media/formidable.mjs b/api/media/formidable.mjs index 25123af..2a427f0 100644 --- a/api/media/formidable.mjs +++ b/api/media/formidable.mjs @@ -6,24 +6,28 @@ import config from '../config.mjs' let lastDateString = '' let incrementor = 1 -export function uploadFile(ctx, siteName) { +export function uploadFile(ctx, siteName, noprefix = false) { return new Promise((res, rej) => { const date = new Date() - // Generate 'YYYYMMDD_HHMMSS_' prefix - let prefix = date - .toISOString() - .replace(/-/g, '') - .replace('T', '_') - .replace(/:/g, '') - .replace(/\..+/, '_') + let prefix = '' - // Append xx_ if same date is hit multiple times - if (prefix === lastDateString) { - prefix += incrementor.toString().padStart('2', '0') + '_' - incrementor++ - } else { - lastDateString = prefix + if (!noprefix) { + // Generate 'YYYYMMDD_HHMMSS_' prefix + prefix = date + .toISOString() + .replace(/-/g, '') + .replace('T', '_') + .replace(/:/g, '') + .replace(/\..+/, '_') + + // Append xx_ if same date is hit multiple times + if (prefix === lastDateString) { + prefix += incrementor.toString().padStart('2', '0') + '_' + incrementor++ + } else { + lastDateString = prefix + } } var form = new formidable.IncomingForm() diff --git a/api/media/routes.mjs b/api/media/routes.mjs index 5d91301..f1ffb35 100644 --- a/api/media/routes.mjs +++ b/api/media/routes.mjs @@ -25,7 +25,7 @@ export default class MediaRoutes { return Promise.all(files.map(file => { return fs.stat(`./public/${folder}/${file}`) .then(function(stat) { - return { name: file, size: stat.size } + return { filename: file, size: stat.size } }) })) }) @@ -40,7 +40,7 @@ export default class MediaRoutes { filesCacheGet(site) { let files = this.siteCache.get(site) || [] return files.sort((a, b) => { - return this.collator.compare(a.name, b.name) + return this.collator.compare(a.filename, b.filename) }) } @@ -49,7 +49,29 @@ export default class MediaRoutes { if (!arr) { this.siteCache.set(site, arr = []) } - arr.push({ name: filename, size: size }) + let found = false + for (let file of arr) { + if (file.filename === filename) { + found = true + file.size = size + break + } + } + if (!found) { + arr.push({ filename: filename, size: size }) + } + } + + filesCacheRemove(site, filename) { + let arr = this.siteCache.get(site) + if (!arr) return + + for (let i = 0; i < arr.length; i++) { + if (arr[i].filename === filename) { + arr.splice(i, 1) + break + } + } } async listFiles(ctx) { @@ -64,11 +86,11 @@ export default class MediaRoutes { ctx.body = this.filesCacheGet(ctx.params.site) } - async upload(ctx) { + async upload(ctx, noprefix = false) { let site = await this.security.verifyToken(ctx) ctx.state.site = site - let result = await this.formidable.uploadFile(ctx, ctx.state.site) + let result = await this.formidable.uploadFile(ctx, ctx.state.site, noprefix) ctx.log.info(`Uploaded ${result.filename}`) @@ -81,15 +103,15 @@ export default class MediaRoutes { } } + uploadNoPrefix(ctx) { + return this.upload(ctx, true) + } + async resize(ctx) { await this.upload(ctx) this.security.verifyBody(ctx) - let out = { - original: ctx.body, - } - let keys = Object.keys(ctx.req.body) await Promise.all(keys.map(key => { @@ -112,7 +134,7 @@ export default class MediaRoutes { if (item.out === 'base64') { let buffer = await sharp.toBuffer() - out[key] = { + ctx.body[key] = { base64: `data:image/${item.format};base64,` + buffer.toString('base64'), } return @@ -122,7 +144,7 @@ export default class MediaRoutes { let stat = await this.fs.stat(`./public/${ctx.state.site}/${target}`) this.filesCacheAdd(ctx.state.site, target, stat.size) - out[key] = { + ctx.body[key] = { filename: target, path: `/${ctx.state.site}/${target}`, } @@ -133,7 +155,18 @@ export default class MediaRoutes { } ) })) + } - ctx.body = out + async remove(ctx) { + let site = await this.security.verifyToken(ctx) + + this.filesCacheRemove(site, ctx.params.filename) + + await this.fs.unlink(`./public/${site}/${ctx.params.filename}`) + .catch(function(err) { + throw new HttpError(`Error removing ${site}/${ctx.params.filename}: ${err.message}`, 422) + }) + + ctx.status = 204 } } diff --git a/api/media/security.mjs b/api/media/security.mjs index 7e8a109..5a0b188 100644 --- a/api/media/security.mjs +++ b/api/media/security.mjs @@ -36,8 +36,8 @@ export function verifyBody(ctx) { let keys = Object.keys(ctx.req.body) for (let key of keys) { - if (key === 'original') { - throw new HttpError('Body item with name original is not allowed', 422) + if (key === 'filename' || key === 'path') { + throw new HttpError('Body item with name filename or path is not allowed', 422) } let item = ctx.req.body[key] diff --git a/api/server.mjs b/api/server.mjs index c5f8231..96b2a59 100644 --- a/api/server.mjs +++ b/api/server.mjs @@ -53,9 +53,11 @@ media.init().then(function() {}, function(err) { log.error(err, 'Error initing media') }) app.get('/media', [QueryHandler()], media.listFiles.bind(media)) -app.get('/media/:site', [QueryHandler()], media.listPublicFiles.bind(media)) +app.get('/media/:site', media.listPublicFiles.bind(media)) app.post('/media', [QueryHandler()], media.upload.bind(media)) +app.post('/media/noprefix', [QueryHandler()], media.uploadNoPrefix.bind(media)) app.post('/media/resize', [QueryHandler()], media.resize.bind(media)) +app.delete('/media/:filename', [QueryHandler()], media.remove.bind(media)) app.listen(config.get('server:port'), function(a,b) { log.info(`Server listening at ${config.get('server:port')}`) diff --git a/test/helper.client.mjs b/test/helper.client.mjs index b0fe449..8b03dad 100644 --- a/test/helper.client.mjs +++ b/test/helper.client.mjs @@ -44,6 +44,7 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options) { }) res.on('end', function () { + if (!output) return resolve(null) try { output = JSON.parse(output) } catch (e) { @@ -66,6 +67,10 @@ Client.prototype.get = function(url = '/') { return this.customRequest('GET', url, null) } +Client.prototype.del = function(url = '/', body = {}) { + return this.customRequest('DELETE', url, JSON.stringify(body)) +} + const random = (length = 8) => { // Declare all characters let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; diff --git a/test/media/api.test.mjs b/test/media/api.test.mjs index c977589..7f07352 100644 --- a/test/media/api.test.mjs +++ b/test/media/api.test.mjs @@ -14,6 +14,8 @@ function resolve(file) { return path.resolve(path.join(__dirname, file)) } +const currYear = new Date().getFullYear().toString() + t.describe('Media (API)', () => { const client = new Client() const secret = 'asdf1234' @@ -23,6 +25,9 @@ t.describe('Media (API)', () => { return Promise.all(testFiles.map(function(file) { return fs.unlink(resolve(`../../public/${file}`)).catch(function() {}) })) + .then(function() { + // return fs.unlink(resolve('../../public/existing/test.png')).catch(function() {}) + }) }) t.timeout(10000).describe('POST /media', function temp() { @@ -83,12 +88,13 @@ t.describe('Media (API)', () => { ) ) + testFiles.push(data.path) + assert.ok(data) assert.ok(data.filename) + assert.ok(data.filename.startsWith(currYear)) assert.ok(data.path) - testFiles.push(data.path) - let stats = await Promise.all([ fs.stat(resolve('test.png')), fs.stat(resolve(`../../public/${data.path}`)), @@ -102,6 +108,83 @@ t.describe('Media (API)', () => { }) }) + t.timeout(10000).describe('POST /media/noprefix', function temp() { + t.test('should require authentication', async () => { + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + let err = await assert.isRejected( + client.upload('/media/noprefix', + resolve('test.png') + ) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Mm]issing/) + + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Mm]issing/) + }) + + t.test('should verify token correctly', async () => { + const assertToken = 'asdf.asdf.asdf' + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + assert.strictEqual(server.log.info.callCount, 0) + + let err = await assert.isRejected( + client.upload('/media/noprefix?token=' + assertToken, + resolve('test.png') + ) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Ii]nvalid/) + + assert.strictEqual(server.log.error.callCount, 1) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/) + assert.ok(server.log.error.lastCall[0] instanceof Error) + assert.match(server.log.error.lastCall[1], new RegExp(assertToken)) + }) + + t.test('should upload and create file with no prefix', async () => { + let token = encode(null, { iss: 'development' }, secret) + + let data = await assert.isFulfilled( + client.upload( + `/media/noprefix?token=${token}`, + resolve('test.png') + ) + ) + + testFiles.push(data.path) + + assert.ok(data) + assert.strictEqual(data.filename, 'test.png') + assert.ok(data.path) + + let stats = await Promise.all([ + fs.stat(resolve('test.png')), + fs.stat(resolve('../../public/development/test.png')), + ]) + assert.strictEqual(stats[0].size, stats[1].size) + + let img = await sharp(resolve('../../public/development/test.png')).metadata() + assert.strictEqual(img.width, 600) + assert.strictEqual(img.height, 700) + assert.strictEqual(img.format, 'png') + }) + }) + t.timeout(10000).describe('POST /media/resize', function temp() { t.test('should require authentication', async () => { resetLog() @@ -162,20 +245,20 @@ t.describe('Media (API)', () => { ) ) - assert.ok(data) - assert.ok(data.original) - assert.ok(data.original.filename) - assert.ok(data.original.path) + testFiles.push(data.path) - testFiles.push(data.original.path) + assert.ok(data) + assert.ok(data.filename) + assert.ok(data.filename.startsWith(currYear)) + assert.ok(data.path) let stats = await Promise.all([ fs.stat(resolve('test.png')), - fs.stat(resolve(`../../public/${data.original.path}`)), + fs.stat(resolve(`../../public/${data.path}`)), ]) assert.strictEqual(stats[0].size, stats[1].size) - let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata() + let img = await sharp(resolve(`../../public/${data.path}`)).metadata() assert.strictEqual(img.width, 600) assert.strictEqual(img.height, 700) assert.strictEqual(img.format, 'png') @@ -212,28 +295,30 @@ t.describe('Media (API)', () => { } ) ) - - assert.ok(data) - assert.ok(data.original) - assert.ok(data.original.filename) - assert.ok(data.original.path) - assert.ok(data.test1.filename) - assert.ok(data.test1.path) - assert.ok(data.test2.filename) - assert.ok(data.test2.path) - - testFiles.push(data.original.path) + testFiles.push(data.path) testFiles.push(data.test1.path) testFiles.push(data.test2.path) + assert.ok(data) + assert.ok(data.filename) + assert.ok(data.filename.startsWith(currYear)) + assert.ok(data.path) + assert.ok(data.test1.filename) + assert.ok(data.test1.filename.startsWith(currYear)) + assert.ok(data.test1.path) + assert.ok(data.test2.filename) + assert.ok(data.test2.filename.startsWith(currYear)) + assert.ok(data.test2.path) + + let stats = await Promise.all([ fs.stat(resolve('test.png')), - fs.stat(resolve(`../../public/${data.original.path}`)), + fs.stat(resolve(`../../public/${data.path}`)), ]) assert.strictEqual(stats[0].size, stats[1].size) - let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata() + let img = await sharp(resolve(`../../public/${data.path}`)).metadata() assert.strictEqual(img.width, 600) assert.strictEqual(img.height, 700) assert.strictEqual(img.format, 'png') @@ -248,7 +333,6 @@ t.describe('Media (API)', () => { assert.strictEqual(img.height, 175) assert.strictEqual(img.format, 'png') }) - t.test('should upload file and support base64 output', async () => { let token = encode(null, { iss: 'development' }, secret) @@ -275,21 +359,20 @@ t.describe('Media (API)', () => { ) assert.ok(data) - assert.ok(data.original) - assert.ok(data.original.filename) - assert.ok(data.original.path) + assert.ok(data.filename) + assert.ok(data.path) assert.ok(data.outtest.base64) - testFiles.push(data.original.path) + testFiles.push(data.path) let stats = await Promise.all([ fs.stat(resolve('test.png')), - fs.stat(resolve(`../../public/${data.original.path}`)), + fs.stat(resolve(`../../public/${data.path}`)), ]) assert.strictEqual(stats[0].size, stats[1].size) - let img = await sharp(resolve(`../../public/${data.original.path}`)).metadata() + let img = await sharp(resolve(`../../public/${data.path}`)).metadata() assert.strictEqual(img.width, 600) assert.strictEqual(img.height, 700) assert.strictEqual(img.format, 'png') @@ -303,6 +386,101 @@ t.describe('Media (API)', () => { }) }) + t.timeout(10000).describe('DELETE /media/:filename', function temp() { + t.test('should require authentication', async () => { + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + let err = await assert.isRejected( + client.del('/media/20220105_101610_test1.jpg', + resolve('test.png') + ) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Mm]issing/) + + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Mm]issing/) + }) + + t.test('should verify token correctly', async () => { + const assertToken = 'asdf.asdf.asdf' + resetLog() + assert.strictEqual(server.log.error.callCount, 0) + assert.strictEqual(server.log.warn.callCount, 0) + assert.strictEqual(server.log.info.callCount, 0) + + let err = await assert.isRejected( + client.del('/media/20220105_101610_test1.jpg?token=' + assertToken, + resolve('test.png') + ) + ) + + assert.strictEqual(err.status, 422) + assert.match(err.message, /[Tt]oken/) + assert.match(err.message, /[Ii]nvalid/) + + assert.strictEqual(server.log.error.callCount, 1) + assert.strictEqual(server.log.warn.callCount, 2) + assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string') + assert.match(server.log.warn.firstCall[0], /[Tt]oken/) + assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/) + assert.ok(server.log.error.lastCall[0] instanceof Error) + assert.match(server.log.error.lastCall[1], new RegExp(assertToken)) + }) + + t.test('should remove the file', async () => { + let token = encode(null, { iss: 'existing' }, secret) + + let data = await client.upload( + `/media/noprefix?token=${token}`, + resolve('test.png') + ) + + let filepath = data.path + testFiles.push(filepath) + + let files = await client.get('/media/existing') + + let found = false + for (let file of files) { + if (file.filename === 'test.png') { + found = true + } + } + assert.ok(found) + + await assert.isFulfilled( + fs.stat(resolve(`../../public/${filepath}`)) + ) + + await assert.isFulfilled( + client.del(`/media/test.png?token=${token}`) + ) + + testFiles.splice(testFiles.length - 1) + + files = await client.get('/media/existing') + + found = false + for (let file of files) { + if (file.filename === 'test.png') { + found = true + } + } + assert.notOk(found) + + await assert.isRejected( + fs.stat(resolve(`../../public/${filepath}`)) + ) + }) + }) + t.describe('GET /media', function() { t.test('should require authentication', async () => { resetLog() @@ -351,7 +529,7 @@ t.describe('Media (API)', () => { assert.ok(data.length) let found = false for (let file of data) { - if (file.name === '.gitkeep' && file.size === 0) { + if (file.filename === '.gitkeep' && file.size === 0) { found = true break } @@ -377,9 +555,9 @@ t.describe('Media (API)', () => { t.test('should otherwise return list of files for a public site', async () => { let files = await client.get('/media/existing') - assert.strictEqual(files[0].name, '20220105_101610_test1.jpg') + assert.strictEqual(files[0].filename, '20220105_101610_test1.jpg') assert.strictEqual(files[0].size, 15079) - assert.strictEqual(files[1].name, '20220105_101610_test2.png') + assert.strictEqual(files[1].filename, '20220105_101610_test2.png') assert.strictEqual(files[1].size, 31705) }) }) diff --git a/test/media/routes.test.mjs b/test/media/routes.test.mjs index 987f91d..833540a 100644 --- a/test/media/routes.test.mjs +++ b/test/media/routes.test.mjs @@ -1,9 +1,23 @@ +import fs from 'fs/promises' import { Eltro as t, assert, stub } from 'eltro' import { createContext } from '../helper.server.mjs' import MediaRoutes from '../../api/media/routes.mjs' import { HttpError } from '../../api/error.mjs' +t.before(function() { + return Promise.all([ + fs.readdir('./public/development').then(files => { + return Promise.all(files.map(function(file) { + if (file !== '.gitkeep') { + return fs.unlink(`./public/development/${file}`).catch(function() {}) + } + return Promise.resolve() + })) + }), + ]) +}) + t.describe('#filesCacheGet', function() { t.test('should return all files in public folder', async function() { const routes = new MediaRoutes() @@ -21,11 +35,11 @@ t.describe('#filesCacheGet', function() { assert.strictEqual(routes.filesCacheGet('existing').length, 2) assert.strictEqual(routes.filesCacheGet('nonexisting').length, 0) - assert.strictEqual(routes.filesCacheGet('development')[0].name, '.gitkeep') + assert.strictEqual(routes.filesCacheGet('development')[0].filename, '.gitkeep') assert.strictEqual(routes.filesCacheGet('development')[0].size, 0) - assert.strictEqual(routes.filesCacheGet('existing')[0].name, '20220105_101610_test1.jpg') + assert.strictEqual(routes.filesCacheGet('existing')[0].filename, '20220105_101610_test1.jpg') assert.strictEqual(routes.filesCacheGet('existing')[0].size, 15079) - assert.strictEqual(routes.filesCacheGet('existing')[1].name, '20220105_101610_test2.png') + assert.strictEqual(routes.filesCacheGet('existing')[1].filename, '20220105_101610_test2.png') assert.strictEqual(routes.filesCacheGet('existing')[1].size, 31705) }) @@ -36,19 +50,19 @@ t.describe('#filesCacheGet', function() { let files = routes.filesCacheGet('existing') - assert.strictEqual(files[0].name, '20220105_101610_test1.jpg') + assert.strictEqual(files[0].filename, '20220105_101610_test1.jpg') assert.strictEqual(files[0].size, 15079) - assert.strictEqual(files[1].name, '20220105_101610_test2.png') + assert.strictEqual(files[1].filename, '20220105_101610_test2.png') assert.strictEqual(files[1].size, 31705) - routes.siteCache.get('existing').push({ name: '0000.png', size: 0 }) + routes.siteCache.get('existing').push({ filename: '0000.png', size: 0 }) files = routes.filesCacheGet('existing') - assert.strictEqual(files[0].name, '0000.png') + assert.strictEqual(files[0].filename, '0000.png') assert.strictEqual(files[0].size, 0) - assert.strictEqual(files[1].name, '20220105_101610_test1.jpg') + assert.strictEqual(files[1].filename, '20220105_101610_test1.jpg') assert.strictEqual(files[1].size, 15079) - assert.strictEqual(files[2].name, '20220105_101610_test2.png') + assert.strictEqual(files[2].filename, '20220105_101610_test2.png') assert.strictEqual(files[2].size, 31705) }) }) @@ -160,19 +174,83 @@ t.describe('#filesCacheAdd', function() { routes.filesCacheAdd('nonexisting', assertName, assertSize) assert.strictEqual(routes.filesCacheGet('nonexisting').length, 1) - assert.strictEqual(routes.filesCacheGet('nonexisting')[0].name, assertName) + assert.strictEqual(routes.filesCacheGet('nonexisting')[0].filename, assertName) assert.strictEqual(routes.filesCacheGet('nonexisting')[0].size, assertSize) }) + + t.test('should not add but update size if file already exists', function() { + const routes = new MediaRoutes() + const assertName = 'asdf.png' + const assertSize = 1234 + const assertNewSize = 5678 + + assert.ok(routes.filesCacheGet('nonexisting')) + assert.ok(Array.isArray(routes.filesCacheGet('nonexisting'))) + assert.strictEqual(routes.filesCacheGet('nonexisting').length, 0) + + routes.filesCacheAdd('nonexisting', assertName, assertSize) + + assert.strictEqual(routes.filesCacheGet('nonexisting').length, 1) + assert.strictEqual(routes.filesCacheGet('nonexisting')[0].filename, assertName) + assert.strictEqual(routes.filesCacheGet('nonexisting')[0].size, assertSize) + + routes.filesCacheAdd('nonexisting', assertName, assertNewSize) + + assert.strictEqual(routes.filesCacheGet('nonexisting').length, 1) + assert.strictEqual(routes.filesCacheGet('nonexisting')[0].filename, assertName) + assert.strictEqual(routes.filesCacheGet('nonexisting')[0].size, assertNewSize) + }) }) -t.describe('#upload', function() { +t.describe('#filesCacheRemove', function() { + t.test('should default not do anything if nothing is found', async function() { + const routes = new MediaRoutes() + + await routes.init() + + assert.ok(routes.filesCacheGet('existing')) + assert.ok(Array.isArray(routes.filesCacheGet('existing'))) + assert.strictEqual(routes.filesCacheGet('existing').length, 2) + + routes.filesCacheRemove('nonexisting', 'bla.text') + routes.filesCacheRemove('nonexisting', 'herp.derp') + routes.filesCacheRemove('existing', 'bla.text') + routes.filesCacheRemove('existing', 'herp.derp') + + assert.ok(routes.filesCacheGet('existing')) + assert.ok(Array.isArray(routes.filesCacheGet('existing'))) + assert.strictEqual(routes.filesCacheGet('existing').length, 2) + }) + + t.test('should otherwise remove entries that match filename', async function() { + const routes = new MediaRoutes() + + await routes.init() + + assert.ok(routes.filesCacheGet('existing')) + assert.ok(Array.isArray(routes.filesCacheGet('existing'))) + assert.strictEqual(routes.filesCacheGet('existing').length, 2) + + routes.filesCacheRemove('existing', '20220105_101610_test1.jpg') + + assert.ok(routes.filesCacheGet('existing')) + assert.ok(Array.isArray(routes.filesCacheGet('existing'))) + assert.strictEqual(routes.filesCacheGet('existing').length, 1) + assert.strictEqual(routes.filesCacheGet('existing')[0].filename, '20220105_101610_test2.png') + }) +}) + +t.describe('#uploadNoPrefix', function() { const stubVerify = stub() const stubUpload = stub() const stubStat = stub() const routes = new MediaRoutes({ - security: { verifyToken: stubVerify }, - formidable: { uploadFile: stubUpload }, + security: { + verifyToken: stubVerify, + verifyBody: stub(), + }, + formidable: { uploadFile: stubUpload, }, fs: { stat: stubStat }, }) @@ -181,21 +259,7 @@ t.describe('#upload', function() { stubUpload.reset() stubStat.reset() } - - t.test('should call security correctly', async function() { - reset() - - let ctx = createContext() - const assertError = new Error('temp') - stubVerify.rejects(assertError) - - let err = await assert.isRejected(routes.upload(ctx)) - - assert.ok(stubVerify.called) - assert.strictEqual(err, assertError) - assert.strictEqual(stubVerify.firstCall[0], ctx) - }) - + t.test('should call upload correctly', async function() { reset() @@ -205,36 +269,13 @@ t.describe('#upload', function() { stubVerify.resolves(assertSiteName) stubUpload.rejects(assertError) - let err = await assert.isRejected(routes.upload(ctx)) + let err = await assert.isRejected(routes.uploadNoPrefix(ctx)) assert.ok(stubUpload.called) assert.strictEqual(err, assertError) assert.strictEqual(stubUpload.firstCall[0], ctx) assert.strictEqual(stubUpload.firstCall[1], assertSiteName) - }) - - t.test('should otherwise set context status to 204 and file in result', async function() { - reset() - - let ctx = createContext() - const assertSize = 1241412 - const assertFilename = 'asdfsafd' - const assertSite = 'mario' - stubVerify.resolves(assertSite) - stubUpload.resolves({ filename: assertFilename }) - stubStat.resolves({ size: assertSize }) - await routes.upload(ctx) - - assert.strictEqual(ctx.body.filename, assertFilename) - assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`) - - assert.ok(stubStat.called) - assert.strictEqual(stubStat.firstCall[0], `./public/${assertSite}/${assertFilename}`) - - let filesFromCache = routes.filesCacheGet(assertSite) - assert.strictEqual(filesFromCache.length, 1) - assert.strictEqual(filesFromCache[0].name, assertFilename) - assert.strictEqual(filesFromCache[0].size, assertSize) + assert.strictEqual(stubUpload.firstCall[2], true) }) }) @@ -292,37 +333,6 @@ t.describe('#resize', function() { routes.siteCache.clear() } - t.test('should call security verifyToken correctly', async function() { - reset() - - let ctx = createContext({ req: { body: { }}}) - const assertError = new Error('temp') - stubVerifyToken.rejects(assertError) - - let err = await assert.isRejected(routes.resize(ctx)) - - assert.ok(stubVerifyToken.called) - assert.strictEqual(err, assertError) - assert.strictEqual(stubVerifyToken.firstCall[0], ctx) - }) - - t.test('should call upload correctly', async function() { - reset() - - let ctx = createContext({ req: { body: { }}}) - const assertSiteName = 'benshapiro' - const assertError = new Error('hello') - stubVerifyToken.resolves(assertSiteName) - stubUpload.rejects(assertError) - - let err = await assert.isRejected(routes.resize(ctx)) - - assert.strictEqual(err, assertError) - assert.ok(stubUpload.called) - assert.strictEqual(stubUpload.firstCall[0], ctx) - assert.strictEqual(stubUpload.firstCall[1], assertSiteName) - }) - t.test('should call security verifyBody correctly', async function() { reset() @@ -338,22 +348,6 @@ t.describe('#resize', function() { assert.strictEqual(stubVerifyBody.firstCall[0], ctx) }) - t.test('should otherwise return original in result', async function() { - reset() - - let ctx = createContext({ req: { body: { }}}) - const assertFilename = 'asdfsafd.png' - const assertSite = 'mario' - stubVerifyToken.resolves(assertSite) - stubUpload.resolves({ filename: assertFilename }) - - await routes.resize(ctx) - - assert.notOk(stubSharp.called) - assert.strictEqual(ctx.body.original.filename, assertFilename) - assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}`) - }) - t.test('should call sharp correctly if items are specified', async function() { reset() const assertKey = 'asdf' @@ -381,8 +375,8 @@ t.describe('#resize', function() { assert.match(stubSharpToFile.firstCall[0], new RegExp(`\/${assertSite}\/${assertFilename}_${assertKey}\.jpg`)) assert.notOk(stubSharpPng.called) assert.notOk(stubSharpResize.called) - assert.strictEqual(ctx.body.original.filename, assertFilename + '.png') - assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}.png`) + assert.strictEqual(ctx.body.filename, assertFilename + '.png') + assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}.png`) assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.jpg`) assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.jpg`) }) @@ -423,16 +417,16 @@ t.describe('#resize', function() { assert.notOk(stubSharpJpeg.called) assert.ok(stubSharpResize.called) assert.strictEqual(stubSharpResize.firstCall[0], assertResize) - assert.strictEqual(ctx.body.original.filename, assertFilename + '.png') - assert.strictEqual(ctx.body.original.path, `/${assertSite}/${assertFilename}.png`) + assert.strictEqual(ctx.body.filename, assertFilename + '.png') + assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}.png`) assert.strictEqual(ctx.body[assertKey].filename, `${assertFilename}_${assertKey}\.png`) assert.strictEqual(ctx.body[assertKey].path, `/${assertSite}/${assertFilename}_${assertKey}.png`) let filesFromCache = routes.filesCacheGet(assertSite) assert.strictEqual(filesFromCache.length, 2) - assert.strictEqual(filesFromCache[0].name, `${assertFilename}_${assertKey}\.png`) + assert.strictEqual(filesFromCache[0].filename, `${assertFilename}_${assertKey}\.png`) assert.strictEqual(filesFromCache[0].size, 20) - assert.strictEqual(filesFromCache[1].name, assertFilename + '.png') + assert.strictEqual(filesFromCache[1].filename, assertFilename + '.png') assert.strictEqual(filesFromCache[1].size, 10) }) @@ -513,6 +507,170 @@ t.describe('#resize', function() { let filesFromCache = routes.filesCacheGet(assertSite) assert.strictEqual(filesFromCache.length, 1) - assert.strictEqual(filesFromCache[0].name, assertFilename) + assert.strictEqual(filesFromCache[0].filename, assertFilename) + }) +}) + +let basicUploadTestRoutes = [ + 'upload', + 'resize', + 'uploadNoPrefix' +] + +basicUploadTestRoutes.forEach(function(name) { + t.describe(`#${name}() Base`, function() { + const stubVerify = stub() + const stubUpload = stub() + const stubStat = stub() + + const routes = new MediaRoutes({ + security: { + verifyToken: stubVerify, + verifyBody: stub(), + }, + formidable: { uploadFile: stubUpload, }, + fs: { stat: stubStat }, + }) + + function reset() { + stubVerify.reset() + stubUpload.reset() + stubStat.reset() + } + + t.test('should call security correctly', async function() { + reset() + + let ctx = createContext({ req: { body: { } } }) + const assertError = new Error('temp') + stubVerify.rejects(assertError) + + let err = await assert.isRejected(routes[name](ctx)) + + assert.ok(stubVerify.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubVerify.firstCall[0], ctx) + }) + + t.test('should call upload correctly', async function() { + reset() + + let ctx = createContext({ req: { body: { } } }) + const assertSiteName = 'benshapiro' + const assertError = new Error('hello') + stubVerify.resolves(assertSiteName) + stubUpload.rejects(assertError) + + let err = await assert.isRejected(routes[name](ctx)) + + assert.ok(stubUpload.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubUpload.firstCall[0], ctx) + assert.strictEqual(stubUpload.firstCall[1], assertSiteName) + }) + + t.test('should otherwise return the file in result', async function() { + reset() + + let ctx = createContext({ req: { body: { } } }) + const assertSize = 1241412 + const assertFilename = 'asdfsafd' + const assertSite = 'mario' + stubVerify.resolves(assertSite) + stubUpload.resolves({ filename: assertFilename }) + stubStat.resolves({ size: assertSize }) + await routes[name](ctx) + + assert.strictEqual(ctx.body.filename, assertFilename) + assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`) + + assert.ok(stubStat.called) + assert.strictEqual(stubStat.firstCall[0], `./public/${assertSite}/${assertFilename}`) + + let filesFromCache = routes.filesCacheGet(assertSite) + assert.strictEqual(filesFromCache.length, 1) + assert.strictEqual(filesFromCache[0].filename, assertFilename) + assert.strictEqual(filesFromCache[0].size, assertSize) + }) + }) +}) + +t.describe('#remove()', function() { + const stubVerify = stub() + const stubUnlink = stub() + const stubCacheRemove = stub() + + const routes = new MediaRoutes({ + security: { + verifyToken: stubVerify, + }, + fs: { unlink: stubUnlink }, + }) + + routes.filesCacheRemove = stubCacheRemove + + function reset() { + stubVerify.reset() + stubUnlink.reset() + stubCacheRemove.reset() + stubUnlink.resolves(null) + } + + t.test('should call security correctly', async function() { + reset() + + let ctx = createContext({ req: { body: { } } }) + const assertError = new Error('temp') + stubVerify.rejects(assertError) + + let err = await assert.isRejected(routes.remove(ctx)) + + assert.ok(stubVerify.called) + assert.strictEqual(err, assertError) + assert.strictEqual(stubVerify.firstCall[0], ctx) + }) + + t.test('should call unlink correctly', async function() { + const assertSiteName = 'benshapiro' + const assertFilename = 'somefilename.png' + const assertErrorMessage = 'Noriyasu Agematsu Tensei' + const assertError = new Error(assertErrorMessage) + + reset() + + let ctx = createContext({ req: { body: { } } }) + ctx.params.filename = assertFilename + stubVerify.resolves(assertSiteName) + stubUnlink.rejects(assertError) + + let err = await assert.isRejected(routes.remove(ctx)) + + assert.ok(stubUnlink.called) + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 422) + assert.match(err.message, new RegExp(assertSiteName)) + assert.match(err.message, new RegExp(assertFilename)) + assert.match(err.message, new RegExp(assertErrorMessage)) + + assert.strictEqual(stubUnlink.firstCall[0], `./public/${assertSiteName}/${assertFilename}`) + }) + + t.test('should otherwise return 204 status and remove from array', async function() { + const assertSiteName = 'benshapiro' + const assertFilename = 'somefilename.png' + + reset() + + let ctx = createContext({ req: { body: { } } }) + ctx.params.filename = assertFilename + stubVerify.resolves(assertSiteName) + + await routes.remove(ctx) + + assert.strictEqual(ctx.status, 204) + assert.ok(stubCacheRemove.called) + assert.strictEqual(stubCacheRemove.firstCall[0], assertSiteName) + assert.strictEqual(stubCacheRemove.firstCall[1], assertFilename) }) }) diff --git a/test/media/security.test.mjs b/test/media/security.test.mjs index c33fea2..b075343 100644 --- a/test/media/security.test.mjs +++ b/test/media/security.test.mjs @@ -173,21 +173,25 @@ t.describe('#verifyBody()', function() { }) }) - t.test('should fail if an item has the name original', function() { - let ctx = createContext({ req: { body: { - original: {} - } } }) + let testInvalidNames = ['filename', 'path'] - assert.throws(function() { verifyBody(ctx) }, function(err) { - assert.ok(err instanceof HttpError) - assert.ok(err instanceof Error) - assert.strictEqual(err.status, 422) - assert.match(err.message, /body/i) - assert.match(err.message, /name/i) - assert.match(err.message, /original/i) - assert.match(err.message, /allowed/i) - return true - }, 'should fail if body item has the name original') + testInvalidNames.forEach(function(invalidName) { + t.test(`should fail if an item has the name ${invalidName}`, function() { + let ctx = createContext({ req: { body: { + [invalidName]: {} + } } }) + + assert.throws(function() { verifyBody(ctx) }, function(err) { + assert.ok(err instanceof HttpError) + assert.ok(err instanceof Error) + assert.strictEqual(err.status, 422) + assert.match(err.message, /body/i) + assert.match(err.message, /name/i) + assert.match(err.message, new RegExp(invalidName)) + assert.match(err.message, /allowed/i) + return true + }, 'should fail if body item has the name original') + }) }) t.test('should require format string present in item', function() {