From bc0ab92e66909d774a8da2c6e4c0c788f1c7ad48 Mon Sep 17 00:00:00 2001 From: "P. Douglas Reeder" Date: Wed, 28 Feb 2024 01:47:16 -0500 Subject: [PATCH] more WIP: modular server: implements storage --- lib/app.js | 2 + lib/middleware/redirectToSSL.js | 2 +- lib/middleware/validUser.js | 2 +- lib/routes/streaming_storage.js | 167 +++++++++++++++++++++++++ lib/routes/webfinger.js | 6 +- lib/routes/well_known.js | 7 +- package-lock.json | 22 ++++ package.json | 1 + spec/armadietto/a_storage_spec.js | 102 ++++++++++++++- spec/modular/m_storage.spec.js | 145 ++++++++++++++++++++++ spec/runner.js | 1 + spec/storage.spec.js | 199 +++++++++++++++++++----------- 12 files changed, 573 insertions(+), 83 deletions(-) create mode 100644 lib/routes/streaming_storage.js create mode 100644 spec/modular/m_storage.spec.js 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..b81ecd49 --- /dev/null +++ b/lib/routes/streaming_storage.js @@ -0,0 +1,167 @@ +/* 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) { + 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 = await req.app.get('streaming store').put(req.params.username, req.blobPath, type, req, condition); + 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')); + } + } +); + +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 index 277fbb92..e712df59 100644 --- a/spec/armadietto/a_storage_spec.js +++ b/spec/armadietto/a_storage_spec.js @@ -3,12 +3,67 @@ 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 = { - get (_username, _path) { - return { item: null, versionMatch: true }; + 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 () { + 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) { // file_store presumes this to be If-Match + if (version !== this.metadata?.ETag) { + return { conflict: true }; + } + } // else unconditional + const created = !this.metadata?.ETag; + const modified = Boolean(this.metadata?.ETag); + this.content = value.toString(); + this.metadata = { contentType, ETag: version }; + this.children = null; + return { created, modified }; }, delete () { }, @@ -27,6 +82,9 @@ const mockStore = { } }; +const sandbox = chai.spy.sandbox(); +const modifiedTimestamp = Date.UTC(2012, 1, 25, 13, 37).toString(); + describe('Storage (monolithic)', function () { before(function () { configureLogger({ log_dir: './test-log', stdout: [], log_files: ['error'] }); @@ -41,4 +99,42 @@ describe('Storage (monolithic)', function () { }); 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('when a valid access token is used', function () { + // it('tells the store to save the given value', async function () { + // 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.with('zebcoe', '/locog/seats', + // 'text/plain', Buffer.from('a value'), null); + // }); + // }) + // }) }); diff --git a/spec/modular/m_storage.spec.js b/spec/modular/m_storage.spec.js new file mode 100644 index 00000000..78348f54 --- /dev/null +++ b/spec/modular/m_storage.spec.js @@ -0,0 +1,145 @@ +/* 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')); + +/** 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'; + } + } // else unconditional + + const result = this.metadata?.ETag ? 'UPDATED' : 'CREATED'; + const ETag = condition?.ETag || this.metadata?.ETag || `"${Math.round(Math.random() * Number.MAX_SAFE_INTEGER)}"`; + this.content = (await readStream.setEncoding('utf-8').toArray())[0]; + this.metadata = { contentType, ETag }; + this.children = null; + return result; + }, + async delete () { + }, + 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(); +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; + }); + + 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 () { + 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 () { + // sandbox.on(this.store, ['put']); + // }); + // + // afterEach(function () { + // sandbox.restore(); + // }); + // + // describe('when a valid access token is used', function () { + // it('tells the store to save the given value', async function () { + // 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' /* ReadableStream, undefined */); + // expect(this.store.content).to.equal('a value') + // }); + // }) + // }); +}); diff --git a/spec/runner.js b/spec/runner.js index 8674bfcc..4a0c523e 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'); diff --git a/spec/storage.spec.js b/spec/storage.spec.js index bfa96b3d..d9fadf8b 100644 --- a/spec/storage.spec.js +++ b/spec/storage.spec.js @@ -6,6 +6,7 @@ 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(); @@ -35,17 +36,16 @@ function del (app, path) { module.exports.shouldCrudBlobs = function () { describe('when the client uses invalid chars in the path', function () { it('returns a 400', async function () { - const res = await chai.request(this.app).get('/storage/zebcoe/locog/$eats'); + 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', async function () { - const res = await chai.request(this.app).get('/storage/zebcoe'); - expect(res).to.have.status(400); - expect(res).to.have.header('Access-Control-Allow-Origin', '*'); + it('returns a 400 or 404', async function () { + const res = await get(this.app, '/storage/zebcoe'); + expect(res.statusCode).to.be.oneOf([400, 404]); }); }); @@ -53,12 +53,13 @@ module.exports.shouldCrudBlobs = 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', 'https://example.com'); - expect(res).to.have.header('Vary', 'Origin'); - expect(res).to.have.header('Access-Control-Allow-Headers', 'Authorization, Content-Length, Content-Type, If-Match, If-None-Match, Origin, X-Requested-With'); - expect(res).to.have.header('Access-Control-Allow-Methods', 'OPTIONS, GET, HEAD, PUT, DELETE'); - expect(res).to.have.header('Access-Control-Expose-Headers', 'Content-Length, Content-Type, ETag'); - expect(res).to.have.header('Cache-Control', 'no-cache'); + 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(''); @@ -76,17 +77,17 @@ module.exports.shouldCrudBlobs = function () { }); it('asks the store for the item', async function () { - await get(this.app, '/storage/zebcoe@local.dev/locog/seats'); + 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@local.dev/locog/seats.gif'); + 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@local.dev/deep/dir/value'); + await get(this.app, '/storage/zebcoe/deep/dir/value'); expect(this.store.get).to.have.been.called.with('zebcoe', '/deep/dir/value'); }); @@ -106,8 +107,9 @@ module.exports.shouldCrudBlobs = function () { }); it('doesn\'t ask the store for a root listing with unauthorized token', async function () { - await get(this.app, '/storage/zebcoe/'); + 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 () { @@ -115,13 +117,6 @@ module.exports.shouldCrudBlobs = function () { expect(this.store.get).to.have.been.called.with('zebcoe', '/'); }); - it('ask the store for an item conditionally based on If-None-Match', async function () { - await get(this.app, '/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}"`); - }); - 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; @@ -181,15 +176,11 @@ module.exports.shouldCrudBlobs = function () { }); }); - const item = { - 'Content-Type': 'custom/type', - ETag: '1330177020000', - value: 'a value' - }; - describe('when the store returns an item', function () { it('returns the value in the response', async function () { - this.store.get = function () { return { item }; }; + 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', '*'); @@ -201,8 +192,9 @@ module.exports.shouldCrudBlobs = function () { }); it('returns a 304 for a failed conditional', async function () { - this.store.get = function () { return { item, versionMatch: true }; }; - const res = await get(this.app, '/storage/zebcoe/locog/seats'); + 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', '*'); @@ -214,16 +206,13 @@ module.exports.shouldCrudBlobs = function () { describe('when the store returns a directory listing', function () { before(function () { - this.store.get = function () { - return { - item: { - items: [ - { bla: { ETag: '1234544444' } }, - { 'bar/': { ETag: '12345888888' } }], - ETag: '12345888888' - } - }; + this.store.metadata = { + ETag: '"12345888888"' }; + this.store.children = [ + { bla: { ETag: '1234544444' } }, + { 'bar/': { ETag: '12345888888' } } + ]; }); it('returns the listing as JSON', async function () { @@ -241,9 +230,8 @@ module.exports.shouldCrudBlobs = function () { describe('when the store returns an empty directory listing', function () { before(function () { - this.store.get = function () { - return { item: { items: {}, ETag: '12345888888' } }; - }; + this.store.metadata = { ETag: '"12345888888"' }; + this.store.children = []; }); it('returns the listing as JSON', async function () { @@ -252,23 +240,23 @@ module.exports.shouldCrudBlobs = function () { 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).to.deep.equal({ - '@context': 'http://remotestorage.io/spec/folder-description', - items: {} - }); + 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.get = function () { return { item: undefined }; }; + this.store.content = null; + this.store.metadata = null; + 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(''); + // expect(res.text).to.equal(''); }); }); @@ -281,7 +269,7 @@ module.exports.shouldCrudBlobs = 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.equal('We did something wrong'); + expect(res.text).to.contain('We did something wrong'); }); }); }); @@ -289,7 +277,6 @@ module.exports.shouldCrudBlobs = function () { describe('PUT', function () { before(function () { sandbox.restore(); - this.store.put = function () { return { created: true }; }; }); afterEach(function () { @@ -302,52 +289,122 @@ module.exports.shouldCrudBlobs = function () { describe('when a valid access token is used', function () { it('tells the store to save the given value', async function () { - await put(this.app, '/storage/zebcoe/locog/seats', 'a value'); + 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', Buffer.from('a value'), null); + 'text/plain' /* ReadableStream, undefined */); + expect(res.status).to.be.oneOf([200, 201, 204]); + expect(res.body).to.deep.equal({}); }); it('tells the store to save a public value', async function () { - await put(this.app, '/storage/zebcoe/public/locog/seats', 'a value'); + 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', Buffer.from('a value'), null); + 'text/plain'); + expect(res.status).to.be.oneOf([200, 201, 204]); + expect(res.body).to.deep.equal({}); + }); + + 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.body).to.deep.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]); }); - it('tells the store to save a value conditionally based on If-None-Match', async function () { - await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + it('tells the store to create a value conditionally based on If-None-Match * (doesn\'t exist)', async function () { + this.store.content = null; + this.store.metadata = null; + 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', `"${modifiedTimestamp}"`) + .set('If-None-Match', '*') .send('a value'); - expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', - 'text/plain', Buffer.from('a value'), `"${modifiedTimestamp}"`); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res).to.have.status(201); + expect(res.body).to.deep.equal({}); }); - it('tells the store to create a value conditionally based on If-None-Match', async function () { - await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + 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', - Buffer.from('a value'), '*'); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res).to.have.status(412); + expect(res.body).to.deep.equal({}); }); - it('tells the store to save a value conditionally based on If-Match', async function () { - await put(this.app, '/storage/zebcoe/locog/seats').buffer(true).type('text/plain') + 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', `"${modifiedTimestamp}"`) + .set('If-Match', oldETag) .send('a value'); - expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain', - Buffer.from('a value'), `"${modifiedTimestamp}"`); + expect(this.store.put).to.have.been.called.with('zebcoe', '/locog/seats', 'text/plain'); + expect(res.status).to.be.oneOf([200, 204]); + expect(res.body).to.deep.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.body).to.deep.equal({}); }); it('does not tell the store to save a directory', async function () { - await put(this.app, '/storage/zebcoe/locog/seats/', 'a value'); + 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.body).to.deep.equal({}); }); it('does not tell the store to save to a write-unauthorized directory', async function () { - await put(this.app, '/storage/zebcoe/books/house_of_leaves', 'a value'); + 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.body).to.deep.equal({}); }); });