From a19e5c4c8873a4d667a6bdb210aa492a470c9126 Mon Sep 17 00:00:00 2001 From: "P. Douglas Reeder" Date: Tue, 27 Feb 2024 03:22:32 -0500 Subject: [PATCH] modular server: implements storage --- .github/workflows/test-and-lint.yml | 1 + lib/app.js | 2 + lib/middleware/redirectToSSL.js | 2 +- lib/middleware/validUser.js | 2 +- lib/routes/streaming_storage.js | 210 +++++++++++ lib/routes/webfinger.js | 6 +- lib/routes/well_known.js | 7 +- package-lock.json | 22 ++ package.json | 1 + spec/armadietto/a_storage_spec.js | 183 +++++++++ spec/modular/m_storage.spec.js | 225 +++++++++++ spec/runner.js | 2 + spec/storage.spec.js | 560 ++++++++++++++++++++++++++++ 13 files changed, 1214 insertions(+), 9 deletions(-) create mode 100644 lib/routes/streaming_storage.js create mode 100644 spec/armadietto/a_storage_spec.js create mode 100644 spec/modular/m_storage.spec.js create mode 100644 spec/storage.spec.js diff --git a/.github/workflows/test-and-lint.yml b/.github/workflows/test-and-lint.yml index 69c4a5fd..b597b0d8 100644 --- a/.github/workflows/test-and-lint.yml +++ b/.github/workflows/test-and-lint.yml @@ -9,6 +9,7 @@ jobs: name: node.js runs-on: ubuntu-latest strategy: + fail-fast: false matrix: # Support LTS versions based on https://nodejs.org/en/about/releases/ node-version: ['18', '20', '21'] diff --git a/lib/app.js b/lib/app.js index 6e79680a..24cc0b3b 100644 --- a/lib/app.js +++ b/lib/app.js @@ -6,6 +6,7 @@ const signupRouter = require('./routes/signup'); const wellKnownRouter = require('./routes/well_known'); const webFingerRouter = require('./routes/webfinger'); const oAuthRouter = require('./routes/oauth'); +const streamingStorageRouter = require('./routes/streaming_storage'); const errorPage = require('./util/errorPage'); const helmet = require('helmet'); const shorten = require('./util/shorten'); @@ -56,6 +57,7 @@ app.use(`${basePath}/.well-known`, wellKnownRouter); app.use(`${basePath}/webfinger`, webFingerRouter); app.use(`${basePath}/oauth`, oAuthRouter); +app.use(`${basePath}/storage`, streamingStorageRouter); // catches 404 and forwards to error handler app.use(basePath, function (req, res, next) { diff --git a/lib/middleware/redirectToSSL.js b/lib/middleware/redirectToSSL.js index aed99973..fbf60076 100644 --- a/lib/middleware/redirectToSSL.js +++ b/lib/middleware/redirectToSSL.js @@ -8,7 +8,7 @@ module.exports = function redirectToSSL (req, res, next) { return next(); } - const host = getHost().split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : ''); + const host = getHost(req).split(':')[0] + (req.app.get('httpsPort') ? ':' + req.app.get('httpsPort') : ''); const newUrl = 'https://' + host + req.url; res.redirect(302, newUrl); diff --git a/lib/middleware/validUser.js b/lib/middleware/validUser.js index 0f76c470..2b3ad60f 100644 --- a/lib/middleware/validUser.js +++ b/lib/middleware/validUser.js @@ -1,7 +1,7 @@ const core = require('../stores/core'); const { logRequest } = require('../logger'); -/** fails request if data.username doesn't pass core.isValidUsername +/** fails request if req.data.username doesn't pass core.isValidUsername * TODO: have store validate username * */ module.exports = function validUser (req, res, next) { diff --git a/lib/routes/streaming_storage.js b/lib/routes/streaming_storage.js new file mode 100644 index 00000000..a9f82e42 --- /dev/null +++ b/lib/routes/streaming_storage.js @@ -0,0 +1,210 @@ +/* eslint-env node */ +/* eslint-disable camelcase */ +const express = require('express'); +const router = express.Router(); +const cors = require('cors'); +const ourCors = cors({ exposedHeaders: 'ETag', maxAge: 7200 }); +const isSecureRequest = require('../util/isSecureRequest'); +const { logRequest } = require('../logger'); +const { getHost } = require('../util/getHost'); +const core = require('../stores/core'); +const { pipeline } = require('node:stream/promises'); + +// const accessStrings = { r: 'Read', rw: 'Read/write' }; + +router.options('/:username/*', + ourCors +); + +/** Express uses GET to generate head */ +router.get('/:username/*', + ourCors, + cacheControl, + validUserParam, + validPathParam, + checkToken.bind(null, 'r'), + async function (req, res, next) { + try { + let condition; + if (req.get('If-Match')) { + condition = { name: 'If-Match', ETag: req.get('If-Match') }; + } else if (req.get('If-None-Match')) { + condition = { name: 'If-None-Match', ETag: req.get('If-None-Match') }; + } + const { readStream, contentLength, contentType, ETag } = await req.app.get('streaming store').get(req.params.username, req.blobPath, condition); + if (!ETag) { + return next(Object.assign(new Error(`No file exists at path “${req.blobPath}”`), { status: 404 })); + } + res.set('Content-Length', contentLength).set('Content-Type', contentType).set('ETag', ETag); + if (readStream) { + res.status(200); + return pipeline(readStream, res); + } else { + res.status(304).end(); + } + } catch (err) { // Express v5 will propagate rejected promises automatically. + next(err); + } + } +); + +router.put('/:username/*', + ourCors, + noRanges, + validUserParam, + validPathParam, + checkToken.bind(null, 'w'), + async function (req, res, next) { + try { + const type = req.get('Content-Type') || 'application/binary'; + let condition; + if (req.get('If-Match')) { + condition = { name: 'If-Match', ETag: req.get('If-Match') }; + } else if (req.get('If-None-Match')) { + condition = { name: 'If-None-Match', ETag: req.get('If-None-Match') }; + } + const [result, ETag] = await req.app.get('streaming store').put(req.params.username, req.blobPath, type, req, condition); + if (ETag) { + res.set('ETag', ETag); + } + switch (result) { + case 'UPDATED': + res.status(204).end(); + break; + case 'CREATED': + res.status(201).end(); + break; + case 'CONFLICT': + res.status(412).end(); + break; + default: + next(new Error('result of store is unknown')); + } + } catch (err) { // Express v5 will propagate rejected promises automatically. + next(err); + } + } +); + +router.delete('/:username/*', + ourCors, + validUserParam, + validPathParam, + checkToken.bind(null, 'w'), + async function (req, res, next) { + try { + let condition = null; + if (req.get('If-Match')) { + condition = { name: 'If-Match', ETag: req.get('If-Match') }; + } else if (req.get('If-None-Match')) { + condition = { name: 'If-None-Match', ETag: req.get('If-None-Match') }; + } + const [result, ETag] = await req.app.get('streaming store').delete(req.params.username, req.blobPath, condition); + if (ETag) { + res.set('ETag', ETag); + } + switch (result) { + case 'DELETED': + res.status(204).end(); + break; + case 'NOT FOUND': + res.status(404).end(); + break; + case 'CONFLICT': + res.status(412).end(); + break; + default: + next(new Error('result of store is unknown')); + } + } catch (err) { // Express v5 will propagate rejected promises automatically. + next(err); + } + } +); + +function cacheControl (req, res, next) { + res.set('Cache-Control', 'no-cache'); + next(); +} + +function validUserParam (req, res, next) { + if (core.isValidUsername(req.params.username)) { return next(); } + + res.status(400).type('text/plain').end(); + + logRequest(req, req.params.username, 400, 0, 'invalid user'); +} + +function validPathParam (req, res, next) { + req.blobPath = req.url.slice(1 + req.params.username.length); + if (core.VALID_PATH.test(req.blobPath)) { + return next(); + } + + res.status(400).type('text/plain').end(); + + logRequest(req, req.params.username, 400, 0, 'invalid user'); +} + +async function checkToken (permission, req, res, next) { + req.token = decodeURIComponent(req.get('Authorization')).split(/\s+/)[1]; + // req.token = req.get('Authorization') + // ? decodeURIComponent(req.get('Authorization')).split(/\s+/)[1] + // : req.data.access_token || req.data.oauth_token; + + if (req.app.get('forceSSL') && !isSecureRequest(req)) { + await req.app.get('streaming store').revokeAccess(req.params.username, req.token); + return unauthorized(req, res, 400, 'invalid_request', 'HTTPS required'); + } + + const isDir = /\/$/.test(req.blobPath); + const isPublic = req.blobPath?.startsWith('/public/'); + // const isPublic = /^\/public\//.test(req.blobPath); + + if (permission === 'r' && isPublic && !isDir) { return next(); } + + const permissions = await req.app.get('streaming store').permissions(req.params.username, req.token); + if (!permissions) { + return unauthorized(req, res, 401, 'invalid_token'); + } + + const blobPathComponents = req.blobPath?.split('/'); + const scopeName = blobPathComponents[1] === 'public' ? blobPathComponents[2] : blobPathComponents[1]; + const scope = '/' + (scopeName ? scopeName + '/' : ''); + const scopePermissions = permissions[scope] || []; + if (scopePermissions.indexOf(permission) < 0) { + return unauthorized(req, res, 403, 'insufficient_scope', `user has permissions '${JSON.stringify(scopePermissions)}' but lacks '${permission}'`); + } + if (permission === 'w' && isDir) { + res.status(400).end(); + return logRequest(req, req.params.username, 400, 0, 'can\'t write to directory'); + } + next(); +} + +function noRanges (req, res, next) { + if (req.get('Content-Range')) { + const msg = 'Content-Range not allowed in PUT'; + res.status(400).type('text/plain').send(msg); + logRequest(req, req.params.username, 400, msg.length, msg); + } else { + next(); + } +} + +/** + * Renders error response + * @param {http.ClientRequest} req + * @param {http.ServerResponse} res + * @param {number} status - HTTP status + * @param {string} errMsg - OAUTH code: invalid_request, access_denied, invalid_scope, etc. + * @param {string|Error} [logMsg] - should concisely give details & be distinct from other calls + */ +function unauthorized (req, res, status, errMsg, logMsg) { + const realm = getHost(req); + res.set('WWW-Authenticate', `Bearer realm="${realm}" error="${errMsg}"`); + res.status(status).end(); + logRequest(req, req.params.username, status, 0, logMsg || errMsg || ''); +} + +module.exports = router; diff --git a/lib/routes/webfinger.js b/lib/routes/webfinger.js index c3b8f861..817079d8 100644 --- a/lib/routes/webfinger.js +++ b/lib/routes/webfinger.js @@ -1,9 +1,10 @@ const express = require('express'); const router = express.Router(); +const cors = require('cors'); const { getHostBaseUrl } = require('../util/getHost'); -router.get('/jrd', handler); -router.get('/xrd', handler); +router.get('/jrd', cors(), handler); +router.get('/xrd', cors(), handler); function handler (req, res) { const resource = req.query.resource; @@ -19,7 +20,6 @@ function handler (req, res) { }] }; - res.set('Access-Control-Allow-Origin', '*'); if (req.path.startsWith('/xrd')) { res.type('application/xrd+xml').render('account.xml', content); } else { diff --git a/lib/routes/well_known.js b/lib/routes/well_known.js index 2e6b56e0..bf8c6f05 100644 --- a/lib/routes/well_known.js +++ b/lib/routes/well_known.js @@ -1,10 +1,11 @@ const express = require('express'); const router = express.Router(); +const cors = require('cors'); const { getHostBaseUrl } = require('../util/getHost'); const WebFinger = require('../controllers/web_finger'); -router.get('/webfinger', handler); -router.get('/host-meta(.json)?', handler); +router.get('/webfinger', cors(), handler); +router.get('/host-meta(.json)?', cors(), handler); function handler (req, res) { const resource = req.query.resource; @@ -13,8 +14,6 @@ function handler (req, res) { const useJRD = req.url.startsWith('/webfinger'); const useJSON = useJRD || (req.url.startsWith('/host-meta') && jsonRequested); - res.set('Access-Control-Allow-Origin', '*'); - let content; if (!resource) { content = { diff --git a/package-lock.json b/package-lock.json index fa8c5849..219b6bee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "argparse": "^2.0.1", + "cors": "^2.8.5", "ejs": "^3.1.9", "express": "^4.18.2", "helmet": "^7.1.0", @@ -1081,6 +1082,18 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -5987,6 +6000,15 @@ "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", diff --git a/package.json b/package.json index 02ec914c..10ec1e29 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "main": "./lib/armadietto.js", "dependencies": { "argparse": "^2.0.1", + "cors": "^2.8.5", "ejs": "^3.1.9", "express": "^4.18.2", "helmet": "^7.1.0", diff --git a/spec/armadietto/a_storage_spec.js b/spec/armadietto/a_storage_spec.js new file mode 100644 index 00000000..f1cd3d37 --- /dev/null +++ b/spec/armadietto/a_storage_spec.js @@ -0,0 +1,183 @@ +/* eslint-env mocha, chai, node */ + +const { configureLogger } = require('../../lib/logger'); +const Armadietto = require('../../lib/armadietto'); +const { shouldCrudBlobs } = require('../storage.spec'); +const chai = require('chai'); +const expect = chai.expect; +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +const spies = require('chai-spies'); +chai.use(spies); +chai.use(require('chai-as-promised')); + +/** This mock needs to implement conditionals, to test the code that generates responses */ +const mockStore = { + content: null, + metadata: null, + children: null, + + get (_username, _path, versions) { + const content = this.content || ''; + const ETag = this.metadata?.ETag?.replace(/"/g, ''); + if (versions) { // If-None-Match presumed + if (versions === this.metadata?.ETag) { + return { versionMatch: true, item: { ETag } }; + } else { + return { + versionMatch: false, + item: { value: content, 'Content-Type': this.metadata.contentType, ETag } + }; + } + } else { // unconditional GET + if (this.children) { + return { + item: { ETag, items: this.children } + }; + } else if (content) { + return { + item: { + value: Buffer.from(content), + 'Content-Length': content?.length, + ETag, + 'Content-Type': this.metadata?.contentType + } + }; + } else { + return { item: undefined }; + } + } + }, + put (_username, _path, contentType, value, version) { + if (version === '*') { // If-None-Match + if (this.metadata?.ETag) { // file exists, so conflict + return { conflict: true }; + } + } else if (version) { // The method signature doesn't allow us to distinguish — file_store presumes this to be If-Match. + if (version !== this.metadata?.ETag) { + return { conflict: true, modified: this.metadata?.ETag.replace(/"/g, '') }; + } + } // else unconditional + const created = !this.metadata?.ETag; + this.content = value.toString(); + const modified = `ETag|${this.content}`; + this.metadata = { contentType, ETag: modified }; + this.children = null; + return { created, modified }; + }, + delete (_username, _path, version) { + if (version) { // The method signature doesn't allow us to distinguish — file_store presumes this to be If-Match. + if (version !== this.metadata?.ETag) { + return { conflict: true, deleted: false, modified: this.metadata?.ETag.replace(/"/g, '') }; + } + } // else unconditional + if (this.metadata?.ETag) { + this.content = this.metadata = this.children = null; + return { deleted: true, modified: this.metadata?.ETag.replace(/"/g, '') }; + } else { + this.content = this.metadata = this.children = null; + return { deleted: false }; + } + }, + permissions (user, token) { + if (user === 'boris' && token === 'a_token') return false; + if (user === 'zebcoe' && token === 'a_token') { + return { + '/locog/': ['r', 'w'], + '/books/': ['r'], + '/statuses/': ['w'], + '/deep/dir/': ['r', 'w'] + }; + } + if (user === 'zebcoe' && token === 'root_token') return { '/': ['r', 'r'] }; + if (user === 'zebcoe' && token === 'bad_token') return false; + } +}; + +const sandbox = chai.spy.sandbox(); +const modifiedTimestamp = Date.UTC(2012, 1, 25, 13, 37).toString(); + +function del (app, path) { + return chai.request(app).delete(path).set('Authorization', 'Bearer a_token'); +} + +describe('Storage (monolithic)', function () { + before(function () { + configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); + + this.store = mockStore; + this.app = new Armadietto({ + bare: true, + store: this.store, + http: { }, + logging: { stdout: [], log_dir: './test-log', log_files: ['debug'] } + }); + }); + + shouldCrudBlobs(); + + describe('GET', function () { + beforeEach(function () { + sandbox.on(this.store, ['get']); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('when a valid access token is used', function () { + it('ask the store for an item conditionally based on If-None-Match', async function () { + await chai.request(this.app).get('/storage/zebcoe/locog/seats') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', `"${modifiedTimestamp}"`).send(); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/seats', `"${modifiedTimestamp}"`); + }); + }); + }); + + // describe('PUT', function () { + // beforeEach(function () { + // sandbox.on(this.store, ['put']); + // }); + // + // afterEach(function () { + // sandbox.restore(); + // }); + // + // }) + + describe('DELETE', function () { + beforeEach(function () { + this.content = this.metadata = this.children = null; + sandbox.on(this.store, ['delete']); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('when the store says the item was deleted', function () { + before(function () { + this.store.delete = function () { return { deleted: true, modified: 1358121717830 }; }; + }); + + it('returns an empty 200 response', async function () { + const res = await del(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(200); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store says there was a version conflict', function () { + beforeEach(function () { + this.store.delete = function () { return { deleted: false, modified: 1358121717830, conflict: true }; }; + }); + + it('returns an empty 412 response', async function () { + const res = await del(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + }); + }); +}); diff --git a/spec/modular/m_storage.spec.js b/spec/modular/m_storage.spec.js new file mode 100644 index 00000000..42458c43 --- /dev/null +++ b/spec/modular/m_storage.spec.js @@ -0,0 +1,225 @@ +/* eslint-env mocha, chai, node */ + +const { Readable } = require('node:stream'); +const { configureLogger } = require('../../lib/logger'); +const { shouldCrudBlobs } = require('../storage.spec'); +const chai = require('chai'); +const expect = chai.expect; +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +const spies = require('chai-spies'); +chai.use(spies); +chai.use(require('chai-as-promised')); + +function put (app, path, params) { + return chai.request(app).put(path).buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token').send(params); +} + +function del (app, path) { + return chai.request(app).delete(path).set('Authorization', 'Bearer a_token'); +} + +/** This mock needs to implement conditionals, to test the code that generates responses */ +const mockStore = { + content: null, + metadata: null, + children: null, + async get (_username, _path, condition) { + let isBodyReturned; + if (condition?.name === 'If-None-Match') { + isBodyReturned = condition.ETag !== this.metadata?.ETag; + } else if (condition?.name === 'If-Match') { + isBodyReturned = condition.ETag === this.metadata?.ETag; + } else { // unconditional + isBodyReturned = Boolean(this.metadata); + } + + let content, contentType; + if (this.children) { + content = JSON.stringify({ + '@context': 'http://remotestorage.io/spec/folder-description', + ETag: this.metadata?.ETag, + items: this.children + }); + contentType = 'application/ld+json'; + } else { + content = this.content || ''; + contentType = this.metadata?.contentType; + } + + return { + readStream: isBodyReturned ? Readable.from(content, { objectMode: false }) : null, + contentLength: content?.length, + contentType, + ETag: this.metadata?.ETag // no ETag means no such file + }; + }, + async put (_username, _path, contentType, readStream, condition) { + if (condition?.name === 'If-None-Match' && condition?.ETag === '*') { + if (this.metadata?.ETag) { + return ['CONFLICT']; + } + } else if (condition?.name === 'If-None-Match') { + if (condition?.ETag === this.metadata?.ETag) { + return ['CONFLICT']; + } + } else if (condition?.name === 'If-Match') { + if (condition?.ETag !== this.metadata?.ETag) { + return ['CONFLICT', this.metadata?.ETag]; + } + } // else unconditional + + const result = this.metadata?.ETag ? 'UPDATED' : 'CREATED'; + this.content = (await readStream.setEncoding('utf-8').toArray())[0]; + const ETag = `"ETag|${this.content}"`; + this.metadata = { contentType, ETag }; + this.children = null; + return [result, ETag]; + }, + async delete (_username, _path, condition) { + if (condition?.name === 'If-None-Match') { + if (condition?.ETag === this.metadata?.ETag) { + return ['CONFLICT']; + } + } else if (condition?.name === 'If-Match') { + if (condition?.ETag !== this.metadata?.ETag) { + return ['CONFLICT', this.metadata?.ETag]; + } + } // else unconditional + if (this.metadata?.ETag) { + this.content = this.metadata = this.children = null; + return ['DELETED', `"ETag|${this.content}"`]; + } else { // didn't exist + this.content = this.metadata = this.children = null; + return ['NOT FOUND']; + } + }, + async permissions (user, token) { + if (user === 'boris' && token === 'a_token') return false; + if (user === 'zebcoe' && token === 'a_token') { + return { + '/locog/': ['r', 'w'], + '/books/': ['r'], + '/statuses/': ['w'], + '/deep/': ['r', 'w'] + }; + } + if (user === 'zebcoe' && token === 'root_token') return { '/': ['r', 'r'] }; + if (user === 'zebcoe' && token === 'bad_token') return false; + } +}; + +const sandbox = chai.spy.sandbox(); +const modifiedTimestamp = Date.UTC(2012, 1, 25, 13, 37).toString(); + +describe('Storage (modular)', function () { + before(async function () { + configureLogger({ log_dir: './test-log', stdout: [], log_files: ['debug'] }); + + this.store = mockStore; + + this.app = require('../../lib/app'); + this.app.set('streaming store', this.store); + this.app.locals.title = 'Test Armadietto'; + this.app.locals.basePath = ''; + this.app.locals.host = 'localhost:xxxx'; + this.app.locals.signup = false; + }); + + describe('GET', function () { + beforeEach(function () { + sandbox.on(this.store, ['get']); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('when a valid access token is used', function () { + it('ask the store for an item conditionally based on If-None-Match', async function () { + const ETag = '"1111aaaa2222"'; + await chai.request(this.app).get('/storage/zebcoe/locog/seats') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', ETag).send(); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/seats', { name: 'If-None-Match', ETag }); + }); + }); + }); + + describe('PUT', function () { + beforeEach(function () { + this.store.content = this.store.metadata = this.store.children = null; + sandbox.on(this.store, ['put']); + }); + + afterEach(function () { + sandbox.restore(); + }); + + describe('when a valid access token is used', function () { + it('tells the store to save a value conditionally based on If-None-Match (does match)', async function () { + const content = 'a value'; + const ETag = '"f5f5f5f5f"'; + this.store.content = content; + this.store.metadata = { contentType: 'text/plain', ETag }; + this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', ETag) + .send(content); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', + 'text/plain'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + expect(res).to.have.header('Content-Length', '0'); + }); + + it('tells the store to save a value conditionally based on If-None-Match (doesn\'t match)', async function () { + const oldETag = '"a1b2c3d4"'; + this.store.content = 'old content'; + this.store.metadata = { contentType: 'text/plain', ETag: oldETag }; + this.store.children = null; + const newContent = 'new content'; + const newETag = '"zzzzyyyyxxxx"'; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', newETag) + .send(newContent); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 204]); + }); + }); + }); + + describe('DELETE', function () { + beforeEach(function () { + sandbox.on(this.store, ['delete']); + }); + + afterEach(function () { + sandbox.restore(); + }); + + it('tells the store to delete an item conditionally based on If-None-Match (doesn\'t match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: '"ETag|old value' }; + const res = await del(this.app, '/storage/zebcoe/locog/seats') + .set('If-None-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.text).to.equal(''); + }); + + it('tells the store to delete an item conditionally based on If-None-Match (does match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: `"${modifiedTimestamp}"` }; + const res = await del(this.app, '/storage/zebcoe/locog/seats').set('If-None-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + }); + + shouldCrudBlobs(); +}); diff --git a/spec/runner.js b/spec/runner.js index 8674bfcc..e92465a2 100644 --- a/spec/runner.js +++ b/spec/runner.js @@ -10,6 +10,7 @@ require('./armadietto/a_static_spec'); require('./armadietto/a_signup_spec'); require('./armadietto/a_web_finger.spec'); require('./armadietto/a_oauth_spec'); +require('./armadietto/a_storage_spec'); require('./stores/file_tree_spec'); // require('./stores/redis_spec'); @@ -20,5 +21,6 @@ require('./modular/m_static.spec'); require('./modular/m_signup.spec'); require('./modular/m_web_finger.spec'); require('./modular/m_oauth.spec'); +require('./modular/m_storage.spec'); // require('./streaming_stores/S3.spec'); diff --git a/spec/storage.spec.js b/spec/storage.spec.js new file mode 100644 index 00000000..3b7fbf38 --- /dev/null +++ b/spec/storage.spec.js @@ -0,0 +1,560 @@ +/* eslint-env mocha, chai, node */ +/* eslint-disable no-unused-expressions */ +const chai = require('chai'); +const expect = chai.expect; +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +const spies = require('chai-spies'); +chai.use(spies); +chai.use(require('chai-as-promised')); + +const sandbox = chai.spy.sandbox(); +const modifiedTimestamp = Date.UTC(2012, 1, 25, 13, 37).toString(); + +function get (app, url) { + return chai.request(app).get(url).set('Authorization', 'Bearer a_token').buffer(true); +} + +function getWithBadToken (app, url) { + return chai.request(app).get(url).set('Authorization', 'Bearer bad_token'); +} + +function put (app, path, params) { + return chai.request(app).put(path).buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token').send(params); +} + +function putWithBadToken (app, path, params) { + return chai.request(app).put(path).buffer(true) + .set('Authorization', 'Bearer bad_token').send(params); +} + +function del (app, path) { + return chai.request(app).delete(path).set('Authorization', 'Bearer a_token'); +} + +/** The original tests assumed data would be passed as a buffer (not a stream) and didn't allow a distinction + * between If-Match and If-None-Match conditions. These tests don't assume a buffer and do allow the distinction. + * TODO: clean up the redundancies */ +module.exports.shouldCrudBlobs = function () { + describe('when the client uses invalid chars in the path', function () { + it('returns a 400', async function () { + const res = await get(this.app, '/storage/zebcoe/locog/$eats'); + expect(res).to.have.status(400); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + }); + }); + + describe('when the client uses a zero-length path', function () { + it('returns a 400 or 404', async function () { + const res = await get(this.app, '/storage/zebcoe'); + expect(res.statusCode).to.be.oneOf([400, 404]); + }); + }); + + describe('OPTIONS', function () { + it('returns access control headers', async function () { + const res = await chai.request(this.app).options('/storage/zebcoe/locog/seats').set('Origin', 'https://example.com'); + expect(res.statusCode).to.be.oneOf([200, 204]); + expect(res).to.have.header('Access-Control-Allow-Origin'); // either * or example.com + expect(res).to.have.header('Vary'); // monlithic server is probably wrong here + expect(res.get('Access-Control-Allow-Methods')).to.contain('GET'); + expect(res.get('Access-Control-Allow-Methods')).to.contain('HEAD'); + expect(res.get('Access-Control-Allow-Methods')).to.contain('PUT'); + expect(res.get('Access-Control-Allow-Methods')).to.contain('DELETE'); + expect(res.get('Access-Control-Expose-Headers')).to.contain('ETag'); + expect(res).to.have.header('Access-Control-Max-Age'); + expect(parseInt(res.header['access-control-max-age'])).to.be.greaterThan(10); + expect(res.text).to.equal(''); + }); + }); + + describe('GET', function () { + describe('when a valid access token is used', function () { + afterEach(function () { + sandbox.restore(); + }); + + beforeEach(function () { + sandbox.on(this.store, ['get']); + }); + + it('asks the store for the item', async function () { + await get(this.app, '/storage/zebcoe/locog/seats'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/seats'); + }); + + it('asks the store for items containing dots', async function () { + await get(this.app, '/storage/zebcoe/locog/seats.gif'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/seats.gif'); + }); + + it('asks the store for a deep item', async function () { + await get(this.app, '/storage/zebcoe/deep/dir/value'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/deep/dir/value'); + }); + + it('passes the path literally to the store', async function () { + await get(this.app, '/storage/zebcoe/locog/a%2Fpath'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/a%2Fpath'); + }); + + it('ask the store for a directory listing', async function () { + await get(this.app, '/storage/zebcoe/locog/'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/locog/'); + }); + + it('ask the store for a deep directory listing', async function () { + await get(this.app, '/storage/zebcoe/deep/dir/'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/deep/dir/'); + }); + + it('doesn\'t ask the store for a root listing with unauthorized token', async function () { + const res = await get(this.app, '/storage/zebcoe/'); + expect(this.store.get).not.to.have.been.called; + expect(res).to.have.status(403); + }); + + it('ask the store for a root listing', async function () { + await get(this.app, '/storage/zebcoe/').set('Authorization', 'Bearer root_token'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/'); + }); + + it('do not ask the store for an item in an unauthorized directory', async function () { + await get(this.app, '/storage/zebcoe/jsconf/tickets'); + expect(this.store.get).not.to.have.been.called; + }); + + it('do not ask the store for an item in an too-broad directory', async function () { + await get(this.app, '/storage/zebcoe/deep/nothing'); + expect(this.store.get).not.to.have.been.called; + }); + + it('do not ask the store for an unauthorized directory', async function () { + await get(this.app, '/storage/zebcoe/deep/'); + expect(this.store.get).not.to.have.been.called; + }); + + it('do not ask the store for an item in a read-unauthorized directory', async function () { + await get(this.app, '/storage/zebcoe/statues/first'); + expect(this.store.get).not.to.have.been.called; + }); + + it('do not ask the store for an item of another user', async function () { + await get(this.app, '/storage/boris/locog/seats'); + expect(this.store.get).not.to.have.been.called; + }); + }); + + describe('when an invalid access token is used', function () { + afterEach(function () { + sandbox.restore(); + }); + + beforeEach(function () { + sandbox.on(this.store, ['get']); + }); + + it('does not ask the store for the item', async function () { + await getWithBadToken(this.app, '/storage/zebcoe/locog/seats'); + expect(this.store.get).not.to.have.been.called; + }); + + it('asks the store for a public item', async function () { + await getWithBadToken(this.app, '/storage/zebcoe/public/locog/seats'); + expect(this.store.get).to.have.been.called.with('zebcoe', '/public/locog/seats'); + }); + + it('does not ask the store for a public directory', async function () { + await getWithBadToken(this.app, '/storage/zebcoe/public/locog/seats/'); + expect(this.store.get).not.to.have.been.called; + }); + + it('returns an OAuth error', async function () { + const res = await getWithBadToken(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(401); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('Cache-Control', 'no-cache'); + expect(res).to.have.header('WWW-Authenticate', /Bearer realm="127\.0\.0\.1:\d{1,5}" error="invalid_token"/); + }); + }); + + describe('when the store returns an item', function () { + it('returns the value in the response', async function () { + this.store.content = 'a value'; + this.store.metadata = { contentType: 'custom/type', ETag: '"1330177020000"' }; + + const res = await get(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(200); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('Cache-Control', 'no-cache'); + expect(res).to.have.header('Content-Length', '7'); + expect(res).to.have.header('Content-Type', 'custom/type'); + expect(res).to.have.header('ETag', '"1330177020000"'); + expect(res.text).to.equal('a value'); + }); + + it('returns a 304 for a failed conditional', async function () { + this.store.content = 'a value'; + this.store.metadata = { contentType: 'custom/type', ETag: '"1330177020000"' }; + const res = await get(this.app, '/storage/zebcoe/locog/seats').set('If-None-Match', this.store.metadata.ETag); + + expect(res).to.have.status(304); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('Cache-Control', 'no-cache'); + expect(res).to.have.header('ETag', '"1330177020000"'); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store returns a directory listing', function () { + before(function () { + this.store.metadata = { + ETag: '"12345888888"' + }; + this.store.children = [ + { bla: { ETag: '1234544444' } }, + { 'bar/': { ETag: '12345888888' } } + ]; + }); + + it('returns the listing as JSON', async function () { + const res = await get(this.app, '/storage/zebcoe/locog/seats/'); + expect(res).to.have.status(200); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('Cache-Control', 'no-cache'); + expect(res).to.have.header('ETag', '"12345888888"'); + expect(res.body.items).to.deep.equal([ + { bla: { ETag: '1234544444' } }, + { 'bar/': { ETag: '12345888888' } } + ]); + }); + }); + + describe('when the store returns an empty directory listing', function () { + before(function () { + this.store.metadata = { ETag: '"12345888888"' }; + this.store.children = []; + }); + + it('returns the listing as JSON', async function () { + const res = await get(this.app, '/storage/zebcoe/locog/seats/'); + expect(res).to.have.status(200); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('Cache-Control', 'no-cache'); + expect(res).to.have.header('ETag', '"12345888888"'); + expect(res.body['@context']).to.equal('http://remotestorage.io/spec/folder-description'); + expect(res.body.items).to.deep.equal([]); + }); + }); + + describe('when the item does not exist', function () { + before(function () { + this.store.content = this.store.metadata = this.store.children = null; + }); + + it('returns an empty 404 response', async function () { + const res = await get(this.app, '/storage/zebcoe/locog/seats/'); + expect(res).to.have.status(404); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + // expect(res.text).to.equal(''); + }); + }); + + describe('when the store returns an error', function () { + before(function () { + this.store.get = function () { throw new Error('We did something wrong'); }; + }); + + it('returns a 500 response with the error message', async function () { + const res = await get(this.app, '/storage/zebcoe/locog/seats/'); + expect(res).to.have.status(500); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res.text).to.contain('We did something wrong'); + }); + }); + }); + + describe('PUT', function () { + before(function () { + sandbox.restore(); + }); + + afterEach(function () { + sandbox.restore(); + }); + + beforeEach(function () { + sandbox.on(this.store, ['put']); + }); + + describe('when a valid access token is used', function () { + it('tells the store to save the given value', async function () { + const res = await chai.request(this.app).put('/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token').send('a value'); + expect(this.store.put).to.have.been.called.once; + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 201, 204]); + expect(res.text).to.equal(''); + }); + + it('tells the store to save a public value', async function () { + const res = await put(this.app, '/storage/zebcoe/public/locog/seats', 'a value'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/public/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 201, 204]); + expect(res.text).to.equal(''); + }); + + // The signature of the old store method (but not the streaming store method) prevents from this working + it.skip('tells the store to save a value conditionally based on If-None-Match (does match)', async function () { + const content = 'a value'; + const ETag = '"f5f5f5f5f"'; + this.store.content = content; + this.store.metadata = { contentType: 'text/plain', ETag }; + this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', ETag) + .send(content); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', + 'text/plain'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + expect(res).to.have.header('Content-Length', '0'); + }); + + // The signature of the old store method (but not the streaming store method) prevents from this working + it.skip('tells the store to save a value conditionally based on If-None-Match (doesn\'t match)', async function () { + const oldETag = '"a1b2c3d4"'; + this.store.content = 'old content'; + this.store.metadata = { contentType: 'text/plain', ETag: oldETag }; + this.store.children = null; + const newContent = 'new content'; + const newETag = '"zzzzyyyyxxxx"'; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', newETag) + .send(newContent); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 204]); + }); + + it('tells the store to create a value conditionally based on If-None-Match * (doesn\'t exist)', async function () { + this.store.content = this.store.metadata = this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', '*') + .send('a value'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res).to.have.status(201); + expect(res.text).to.equal(''); + }); + + it('tells the store to create a value conditionally based on If-None-Match * (does exist)', async function () { + const oldETag = '"OldOldOld"'; + this.store.content = 'old content'; + this.store.metadata = { contentType: 'text/plain', ETag: oldETag }; + this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-None-Match', '*') + .send('a value'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + + it('tells the store to save a value conditionally based on If-Match (does match)', async function () { + const oldETag = '"OldOldOld"'; + this.store.content = 'a value'; + this.store.metadata = { contentType: 'text/plain', ETag: oldETag }; + this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-Match', oldETag) + .send('a value'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.text).to.equal(''); + }); + + it('tells the store to save a value conditionally based on If-Match (doesn\'t match)', async function () { + const oldETag = '"OldOldOld"'; + this.store.content = 'old value'; + this.store.metadata = { contentType: 'text/plain', ETag: oldETag }; + this.store.children = null; + const res = await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + .set('Authorization', 'Bearer a_token') + .set('If-Match', '"NewNewNew') + .send('new value'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + + it('does not tell the store to save a directory', async function () { + const res = await put(this.app, '/storage/zebcoe/locog/seats/', 'a value'); + expect(this.store.put).not.to.have.been.called; + expect(res).to.have.status(400); + expect(res.text).to.equal(''); + }); + + it('does not tell the store to save to a write-unauthorized directory', async function () { + const res = await put(this.app, '/storage/zebcoe/books/house_of_leaves', 'a value'); + expect(this.store.put).not.to.have.been.called; + expect(res).to.have.status(403); + expect(res.text).to.equal(''); + }); + }); + + describe('when an invalid access token is used', function () { + it('does not tell the store to save the given value', async function () { + await putWithBadToken(this.app, '/storage/zebcoe/locog/seats', 'a value'); + expect(this.store.put).not.to.have.been.called; + }); + }); + + describe('when the store says the item was created', function () { + before(function () { + this.store.content = this.store.metadata = this.store.children = null; + }); + + it('returns an empty 201 response', async function () { + const res = await put(this.app, '/storage/zebcoe/locog/seats', 'a value'); + expect(res).to.have.status(201); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('ETag', '"ETag|a value"'); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store says the item was not created but updated', function () { + before(function () { + this.store.content = 'Old value'; + this.store.metadata = { contentType: 'text/html', ETag: '"ETag|Old value"' }; + this.store.children = null; + }); + + it('returns an empty 200 response', async function () { + const res = await put(this.app, '/storage/zebcoe/locog/seats', 'new value'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('ETag', '"ETag|new value"'); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store says there was a version conflict', function () { + before(function () { + this.store.content = 'stored value'; + this.store.metadata = { contentType: 'text/html', ETag: '"ETag|stored value"' }; + this.store.children = null; + }); + + it('returns an empty 412 response', async function () { + const res = await put(this.app, '/storage/zebcoe/locog/seats', 'new value').set('If-Match', 'ETag|some other value'); + expect(res).to.have.status(412); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res).to.have.header('ETag', '"ETag|stored value"'); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store returns an error', function () { + before(function () { + this.store.put = function () { throw new Error('Something is technically wrong'); }; + }); + + it('returns a 500 response with the error message', async function () { + const res = await put(this.app, '/storage/zebcoe/locog/seats', 'a value'); + expect(res).to.have.status(500); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res.text).to.contain('Something is technically wrong'); + }); + }); + }); + + describe('DELETE', function () { + afterEach(function () { + sandbox.restore(); + }); + + beforeEach(function () { + this.store.content = this.store.metadata = this.store.children = null; + sandbox.on(this.store, ['delete']); + }); + + it('tells the store to delete the given item unconditionally', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: '"ETag|old value' }; + const res = await del(this.app, '/storage/zebcoe/locog/seats'); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats', null); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.text).to.equal(''); + }); + + // The signature of the old store method (but not the streaming store method) prevents from this working + it.skip('tells the store to delete an item conditionally based on If-None-Match (doesn\'t match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: '"ETag|old value' }; + const res = await del(this.app, '/storage/zebcoe/locog/seats') + .set('If-None-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.text).to.equal(''); + }); + + // The signature of the old store method (but not the streaming store method) prevents from this working + it.skip('tells the store to delete an item conditionally based on If-None-Match (does match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: `"${modifiedTimestamp}"` }; + const res = await del(this.app, '/storage/zebcoe/locog/seats').set('If-None-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + + it('tells the store to delete an item conditionally based on If-Match (doesn\'t match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: '"ETag|old value' }; + const res = await del(this.app, '/storage/zebcoe/locog/seats').set('If-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res).to.have.status(412); + expect(res.text).to.equal(''); + }); + + it('tells the store to delete an item conditionally based on If-Match (does match)', async function () { + this.store.content = 'old value'; + this.store.metadata = { ETag: `"${modifiedTimestamp}"` }; + const res = await del(this.app, '/storage/zebcoe/locog/seats').set('If-Match', `"${modifiedTimestamp}"`); + expect(this.store.delete).to.have.been.called.with('zebcoe', '/locog/seats'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.text).to.equal(''); + }); + + describe('when the item does not exist', function () { + beforeEach(function () { + }); + + it('returns an empty 404 response', async function () { + this.store.content = this.store.metadata = this.store.children = null; + const res = await del(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(404); + expect(res.text).to.equal(''); + }); + }); + + describe('when the store returns an error', function () { + beforeEach(function () { + this.store.delete = function () { throw new Error('OH NOES!'); }; + }); + + it('returns a 500 response with the error message', async function () { + const res = await del(this.app, '/storage/zebcoe/locog/seats'); + expect(res).to.have.status(500); + expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + expect(res.text).to.contain('OH NOES!'); + }); + }); + }); +};