diff --git a/content/router.xql b/content/router.xql index 02133f1..cdc8865 100644 --- a/content/router.xql +++ b/content/router.xql @@ -239,59 +239,105 @@ function router:middleware-reducer ( array { apply($next-middleware, $args) } }; -(:~ - : Try to retrieve and convert the request body if specified - :) -declare function router:body ($request as map(*)) { - if ( - not(map:contains($request?config, "requestBody")) or - not(map:contains($request?config?requestBody, "content")) - ) - then () (: this route expects no body, return an empty sequence :) +declare %private function router:content-type ($request as map(*)) as map(*) { + if (not(exists($request?config?requestBody?content))) + then (map{}) (: this route expects no body, return an empty map :) + else if (not($request?config?requestBody?content instance of map(*))) + then error($errors:OPERATION, "requestBody.content is not defined correctly", $request?config) else ( - let $content-type-header := (: strip charset info from mime-type if present :) - request:get-header("Content-Type") - => replace("^([^;]+);?.*$", "$1") + let $defined-content-types := map:keys($request?config?requestBody?content) + + let $raw-content-type-header-value := request:get-header("Content-Type") + let $media-type := + if (contains($raw-content-type-header-value, ";")) + then substring-before($raw-content-type-header-value, ";") + else $raw-content-type-header-value - let $definition := $request?config?requestBody?content + let $charset := + if (contains($raw-content-type-header-value, "charset=")) + then ( + substring-after($raw-content-type-header-value, "charset=") + => lower-case() + => replace("^([a-z0-9\-]+).*$", "$1") + ) + else () + + let $registry := substring-before($media-type, "/") + let $format-hint := substring-after($media-type, "+") return if ( - map:contains($definition, $content-type-header) or - map:contains($definition, "*/*") + $media-type = $defined-content-types or ( + $defined-content-types = "*/*" and + $registry = ( + "application", "audio", "example", "font", "image", + "message", "model", "multipart", "text", "video" + ) + ) ) - then ( - switch ($content-type-header) - case "multipart/form-data" return - () (: TODO: implement form-data handling? :) - case "application/json" return - try { - request:get-data() => util:binary-to-string() => parse-json() - } - catch * { - error($errors:BODY_CONTENT_TYPE, "Invalid JSON", $err:description) - } - case "application/xml" return - try { - (: - : workaround for eXist-DB specific behaviour, - : this way we will get parse errors as early as possible - : while still having access to the data afterwards - :) - let $data := request:get-data() - let $_test := parse-xml($data) - (: return root node instead of a document fragment :) - return $data/node() - } - catch * { - error($errors:BODY_CONTENT_TYPE, "Invalid XML", $err:description) - } - default return - request:get-data() + then map { + "media-type": $media-type, + "charset": $charset, + "registry": $registry, + "format": + if ($media-type = ("application/json", "text/json")) + then "json" + else if ($media-type = ("application/xml", "text/xml")) + then "xml" + else if ($media-type = "multipart/form-data") + then "form-data" + else if ($format-hint) + then $format-hint + else "binary" + } + else error( + $errors:BODY_CONTENT_TYPE, + "Body with media-type '" || $media-type || "' is not allowed", + $request ) - else - error($errors:BODY_CONTENT_TYPE, - "Passed in Content-Type " || $content-type-header || " not allowed") + ) +}; + +(:~ + : Try to retrieve and convert the request body if specified + :) +declare function router:body ($request as map(*)) { + if (not(exists($request?media-type))) + then () (: this route expects no body, return an empty sequence :) + else ( + try { + switch ($request?format) + (: Q: do we need to handle form-data? :) + case "form-data" return () + (: + Parse body contents to XQuery data structure for media types + that were identified as being in JSON format. + NOTE: The data needs to be serialized again before it can be stored. + :) + case "json" return + request:get-data() + => util:binary-to-string() + => parse-json() + (: + Workaround for eXist-DB specific behaviour, + this way we will get parse errors as early as possible + while still having access to the data afterwards. + NOTE: Returns the root node instead of a document (fragment). + :) + case "xml" return + let $data := request:get-data() + let $_test := parse-xml($data) + return $data/node() + (: Treat everything else as binary data :) + default return request:get-data() + } + catch * { + error( + $errors:BODY_CONTENT_TYPE, + "Body with media type '" || $request?media-type || "' could not be parsed (invalid " || upper-case($request?format) || ").", + $err:description + ) + } ) }; @@ -304,7 +350,8 @@ declare %private function router:execute-handler ($base-request as map(*), $use, error($errors:OPERATION, "Operation does not define an operationId", $base-request?config) else try { - let $request-with-body := map:put($base-request, "body", router:body($base-request)) + let $request-with-content-type := map:merge(($base-request, router:content-type($base-request))) + let $request-with-body := map:put($request-with-content-type, "body", router:body($request-with-content-type)) let $request-response-array := fold-left($use, [$request-with-body, map {}], router:middleware-reducer#2) @@ -523,18 +570,35 @@ declare %private function router:safe-set-header ($header as xs:string, $value a (:~ : Q: binary types? + : XSLT default values: "xml", "xhtml", "html", "text", "json", "adaptive" + : "html5" is an eXist-DB provided extension :) declare %private function router:method-for-content-type ($type as xs:string) as xs:string { - switch($type) - case "application/json" return "json" - case "text/html" return "html5" - case "text/text" return "text" - case "application/octet-stream" return "text" - case "application/xml" return "xml" - default return "xml" + switch (substring-before($type, "/")) + case "application" return + if (ends-with($type, "json")) then "json" (: matches application/json and any type ending in +json :) + else if (ends-with($type, "xhtml+xml")) then "xhtml" + else if (ends-with($type, "xml")) then "xml" (: matches application/xml and any type ending in +xml :) + else "text" + case "text" return + if (ends-with($type, "/xml")) then "xml" + else if (ends-with($type, "/html")) then "html5" + else "text" + case "image" return + if (ends-with($type, "/svg+xml")) then "xml" + else "text" + case "multipart" + case "audio" + case "font" + case "example" + case "message" + case "model" + case "video" return + "text" (: assume binary content :) + default return + error($errors:OPERATION, "Unknown media type '" || $type || '"') }; - (: helpers :) declare %private function router:is-response-map($value as item()*) as xs:boolean { diff --git a/icon.svg b/icon.svg index 846f231..4ad5144 100644 --- a/icon.svg +++ b/icon.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/test/app/api.json b/test/app/api.json index 23e28c3..f99d62a 100644 --- a/test/app/api.json +++ b/test/app/api.json @@ -468,6 +468,24 @@ } } }, + "/api/avatar": { + "get": { + "summary": "Retrieve avatar as SVG", + "operationId": "api:avatar", + "responses": { + "200": { + "description": "Not found", + "content": { + "image/svg+xml":{ + "schema": { + "type": "object" + } + } + } + } + } + } + }, "/api/errors/handle": { "get": { "summary": "Test error handler", diff --git a/test/app/modules/api.xql b/test/app/modules/api.xql index 106d6e1..493a6d5 100644 --- a/test/app/modules/api.xql +++ b/test/app/modules/api.xql @@ -2,6 +2,7 @@ xquery version "3.1"; declare namespace api="http://e-editiones.org/roasted/test-api"; declare namespace output="http://www.w3.org/2010/xslt-xquery-serialization"; +declare namespace svg="http://www.w3.org/2000/svg"; import module namespace roaster="http://e-editiones.org/roaster"; @@ -98,6 +99,17 @@ declare function api:get-uploaded-data ($request as map(*)) { ) }; +declare function api:avatar ($request as map(*)) { + + { + for $pos in 1 to 10 + let $zero-based := $pos - 1 + let $x := $zero-based mod 4 * 3 + 2 + let $y := $zero-based idiv 4 * 3 + 2 + return + } + +}; (: end of route handlers :) @@ -110,4 +122,5 @@ declare function api:lookup ($name as xs:string) { function-lookup(xs:QName($name), 1) }; +(: util:declare-option("output:indent", "no"), :) roaster:route($api:definitions, api:lookup#1) diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..3b689c6 --- /dev/null +++ b/test/auth.test.js @@ -0,0 +1,41 @@ +const util = require('./util.js') +const chai = require('chai') +const expect = chai.expect + +describe('On Login', function () { + let response + + before(async function () { + await util.login() + response = await util.axios.get('api/parameters', {}) + }) + + it('public route can be called', function () { + expect(response.status).to.equal(200); + }) + + it('user property is set on request map', function () { + expect(response.data.user).to.be.a('object') + expect(response.data.user.name).to.equal("admin") + expect(response.data.user.dba).to.equal(true) + }) + + describe('On logout', function () { + let logoutResponse + let guestResponse + before(async function () { + logoutResponse = await util.axios.get('logout') + guestResponse = await util.axios.get('api/parameters', {}) + }) + it('request returns true', function () { + expect(logoutResponse.status).to.equal(200) + expect(logoutResponse.data.success).to.equal(true) + }) + it('public route sets guest as user', function () { + expect(guestResponse.status).to.equal(200) + expect(guestResponse.data.user.name).to.equal("guest") + expect(guestResponse.data.user.dba).to.equal(false) + }) + + }) +}) diff --git a/test/error.test.js b/test/error.test.js new file mode 100644 index 0000000..0c3f75a --- /dev/null +++ b/test/error.test.js @@ -0,0 +1,41 @@ +const util = require('./util.js') +const chai = require('chai') +const expect = chai.expect + +describe('Error reporting', function() { + it('receives error report', function() { + return util.axios.get('api/errors') + .catch(function(error) { + expect(error.response.status).to.equal(404) + expect(error.response.data.description).to.equal('document not found') + expect(error.response.data.value).to.equal('error details') + }) + }) + + it('receives dynamic XQuery error', function() { + return util.axios.post('api/errors') + .catch(function(error) { + expect(error.response.status).to.equal(500) + expect(error.response.data.line).to.match(/\d+/) + expect(error.response.data.description).to.contain('$undefined') + }) + }) + + it('receives explicit error', function() { + return util.axios.delete('api/errors') + .catch(function(error) { + expect(error.response.status).to.equal(403) + expect(error.response.headers['content-type']).to.equal('application/xml') + expect(error.response.data).to.equal('') + }) + }) + + it('calls error handler', function() { + return util.axios.get('api/errors/handle') + .catch(function(error) { + expect(error.response.status).to.equal(500) + expect(error.response.headers['content-type']).to.equal('text/html') + expect(error.response.data).to.contain('$undefined') + }) + }) +}) diff --git a/test/mediatype.test.js b/test/mediatype.test.js new file mode 100644 index 0000000..453c0ee --- /dev/null +++ b/test/mediatype.test.js @@ -0,0 +1,303 @@ +const util = require('./util.js') +const chai = require('chai') +const expect = chai.expect + +const fs = require('fs') +const dbUploadCollection = '/db/apps/roasted/uploads/' + +describe("Binary up and download", function () { + const contents = fs.readFileSync("./roasted.xar") + + describe("using basic authentication", function () { + const filename = 'roasted.xar' + it('handles post of binary data', async function () { + const res = await util.axios.post('api/paths/' + filename, contents, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Authorization': 'Basic YWRtaW46' + } + }) + expect(res.status).to.equal(201) + expect(res.data).to.equal(dbUploadCollection + filename) + }) + it('retrieves the data', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data).to.eql(contents) + }) + }) + + describe("using cookie authentication", function () { + const filename = "roasted2.xar" + before(async function () { + await util.login() + }) + after(async function () { + await util.logout() + }) + + it('handles post of binary data', async function () { + const res = await util.axios.post('api/paths/' + filename, contents, { + headers: { 'Content-Type': 'application/octet-stream' } + }) + expect(res.status).to.equal(201) + expect(res.data).to.equal(dbUploadCollection + filename) + }) + it('retrieves the data', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data).to.eql(contents) + }) + }) +}) + +describe("body with content-type application/xml", function () { + before(async function () { + await util.login() + }) + after(async function () { + await util.logout() + }) + + describe("with valid content", function () { + let uploadResponse + const filename = 'valid.xml' + const contents = Buffer.from('') + before(function () { + return util.axios.post('api/paths/' + filename, contents, { + headers: { 'Content-Type': 'application/xml' } + }) + .then(r => uploadResponse = r) + .catch(e => { + console.error(e.response.data) + uploadResponse = e.response + }) + }) + it("is uploaded", function () { + expect(uploadResponse.status).to.equal(201) + expect(uploadResponse.data).to.equal(dbUploadCollection + filename) + }) + it('can be retrieved', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data).to.eql(contents) + }) + }) + + // this "feature" is quite buggy and is only here to test for other encodings + // bottom line - don't use it + describe("with valid content encoded in latin1", function () { + let uploadResponse + const filename = 'latin1.xml' + const contents = Buffer.from(` +<örtchen name="München" />`, 'latin1') + const dbNormalizedValue = '<örtchen name="München"/>' + + before(function () { + return util.axios.post('api/paths/' + filename, contents, { + headers: { 'Content-Type': 'application/xml; charset=iso-8859-1' } + }) + .then(r => uploadResponse = r) + .catch(e => { + console.error(e.response.data) + uploadResponse = e.response + }) + }) + it("is uploaded", function () { + expect(uploadResponse.status).to.equal(201) + expect(uploadResponse.data).to.equal(dbUploadCollection + filename) + }) + it('can is stored encoded in UTF-8 and normalized', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data.toString('utf-8')).to.equal(dbNormalizedValue) + }) + }) + + describe("with invalid content", function () { + let uploadResponse + before(async function () { + return util.axios.post('api/paths/invalid.xml', Buffer.from('asdf'), { + headers: { 'Content-Type': 'application/xml' } + }) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response) + }) + it("is rejected as Bad Request", function () { + expect(uploadResponse.status).to.equal(400) + }) + it("with the correct error code", function () { + expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') + }) + it("with a human readable description", function () { + expect(uploadResponse.data.description).to.equal('Body with media type \'application/xml\' could not be parsed (invalid XML).') + }) + }) +}) + +describe("body with content-type application/tei+xml", function () { + before(async function () { + await util.login() + }) + after(async function () { + await util.logout() + }) + + describe("with valid content", function () { + let uploadResponse + const filename = 'valid.tei.xml' + const contents = Buffer.from('') + before(function () { + return util.axios.post('api/paths/' + filename, contents, { + headers: { 'Content-Type': 'application/tei+xml' } + }) + .then(r => uploadResponse = r) + .catch(e => { + console.error(e) + uploadResponse = e.response + }) + }) + it("is uploaded", function () { + expect(uploadResponse.status).to.equal(201) + expect(uploadResponse.data).to.equal(dbUploadCollection + filename) + }) + it('can be retrieved', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data).to.eql(contents) + }) + }) + + describe("with invalid content", function () { + let uploadResponse + before(async function () { + return util.axios.post('api/paths/invalid.tei.xml', Buffer.from('asdf'), { + headers: { 'Content-Type': 'application/tei+xml' } + }) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response) + }) + it("is rejected as Bad Request", function () { + expect(uploadResponse.status).to.equal(400) + }) + it("with the correct error code", function () { + expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') + }) + it("with a human readable description", function () { + expect(uploadResponse.data.description).to.equal('Body with media type \'application/tei+xml\' could not be parsed (invalid XML).') + }) + }) +}) + +describe("body with content-type application/json", function () { + before(async function () { + await util.login() + }) + after(async function () { + await util.logout() + }) + describe("with valid content", function () { + let uploadResponse + const filename = 'valid.json' + const contents = Buffer.from('{"valid":[]}') + before(function () { + return util.axios.post( + 'api/paths/' + filename, + contents, + { headers: { 'Content-Type': 'application/json'} } + ) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response) + }) + it("is uploaded", function () { + expect(uploadResponse.status).to.equal(201) + expect(uploadResponse.data).to.equal(dbUploadCollection + filename) + }) + it('can be retrieved', async function () { + const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }) + expect(res.status).to.equal(200) + expect(res.data).to.eql(contents) + }) + }) + + describe("with invalid content", function () { + let uploadResponse + before(function () { + return util.axios.post( + 'api/paths/invalid.json', + '{"invalid: ()', + { headers: { 'Content-Type': 'application/json' } } + ) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response) + }) + it("is rejected as Bad Request", function () { + expect(uploadResponse.status).to.equal(400) + }) + it("with the correct error code", function () { + expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') + }) + it("with a human readable description", function () { + expect(uploadResponse.data.description).to.equal('Body with media type \'application/json\' could not be parsed (invalid JSON).') + }) + }) +}) + +describe("with invalid content-type header", function () { + let uploadResponse + before(function () { + return util.axios.post( + 'api/paths/invalid.stuff', + 'asd;lfkjdas;flkja', + { + headers: { + 'Content-Type': 'my/thing', + 'Authorization': 'Basic YWRtaW46' + } + } + ) + .then(r => uploadResponse = r) + .catch(e => uploadResponse = e.response) + }) + it("is rejected as Bad Request", function () { + expect(uploadResponse.status).to.equal(400) + }) + it("with the correct error code", function () { + expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') + }) + it("with a human readable description", function () { + expect(uploadResponse.data.description).to.equal('Body with media-type \'my/thing\' is not allowed') + }) +}) + +describe("Retrieving an SVG image", function () { + let response + const avatarImage = + '\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '' + + before(async function () { + response = await util.axios.get('api/avatar') + }) + it("should succeed", function () { + expect(response.status).to.equal(200) + }) + it("was sent with the correct Content-Type header", function () { + expect(response.headers['content-type']).to.equal('image/svg+xml') + }) + it("is pretty printed", function () { + expect(response.data).to.equal(avatarImage) + }) +}) diff --git a/test/paths.test.js b/test/paths.test.js index d5f1e59..57a9040 100644 --- a/test/paths.test.js +++ b/test/paths.test.js @@ -1,29 +1,6 @@ -const util = require('./util.js'); -const path = require('path'); -const fs = require('fs'); -const chai = require('chai'); -const expect = chai.expect; -const chaiResponseValidator = require('chai-openapi-response-validator'); - -const spec = path.resolve("./test/app/api.json"); -chai.use(chaiResponseValidator(spec)); - -const dbUploadCollection = '/db/apps/roasted/uploads/' - -describe('Requesting a static file from the server', function () { - it('will download the resource', async function () { - const res = await util.axios.get('static/roaster-router-logo.png'); - expect(res.status).to.equal(200); - }); -}); - -describe('Path parameters', function () { - it('handles get of path including $', async function () { - const res = await util.axios.get('api/$op-er+ation*!'); - expect(res.status).to.equal(200); - // expect(res).to.satisfyApiSpec; - }); -}); +const util = require('./util.js') +const chai = require('chai') +const expect = chai.expect describe('Request methods on api/$op-er+ation*! route', function (){ const route = 'api/$op-er+ation*!' @@ -36,31 +13,31 @@ describe('Request methods on api/$op-er+ation*! route', function (){ return util.axios.get(route) .then(r => expect(r.status).to.equal(200)) .catch(fail) - }); + }) it('should handle defined POST request', function () { return util.axios.post(route, {}) .then(r => expect(r.status).to.equal(200)) .catch(fail) - }); + }) it('should reject a HEAD request', function () { return util.axios.head(route) .then(fail) .catch(expectNotAllowed) - }); + }) it('should reject a PUT request', function () { return util.axios.put(route) .then(fail) .catch(expectNotAllowed) - }); + }) it('should reject a DELETE request', function () { return util.axios.delete(route) .then(fail) .catch(expectNotAllowed) - }); + }) // please note that HTTP PATCH is available in // exist since v5.3.0 and after @@ -68,7 +45,7 @@ describe('Request methods on api/$op-er+ation*! route', function (){ return util.axios.patch(route) .then(fail) .catch(expectNotAllowedOrNotImplented) - }); + }) // OPTIONS request is handled by Jetty and will not reach your controller, // nor roaster API @@ -76,7 +53,7 @@ describe('Request methods on api/$op-er+ation*! route', function (){ return util.axios.options(route) .then(r => expect(r.status).to.equal(200)) .catch(fail) - }); + }) // exist DB does not handle custom request methods, which is why this will // return with error 501 instead @@ -87,7 +64,7 @@ describe('Request methods on api/$op-er+ation*! route', function (){ }) .then(fail) .catch(expectNotAllowedOrNotImplented) - }); + }) }) describe('Prefixed known path', function () { @@ -100,157 +77,6 @@ describe('Prefixed known path', function () { }); }); -describe("Binary up and download", function () { - const contents = fs.readFileSync("./roasted.xar") - - describe("using basic authentication", function () { - const filename = 'roasted.xar' - it('handles post of binary data', async function () { - const res = await util.axios.post('api/paths/' + filename, contents, { - headers: { - 'Content-Type': 'application/octet-stream', - 'Authorization': 'Basic YWRtaW46' - } - }); - expect(res.status).to.equal(201); - expect(res.data).to.equal(dbUploadCollection + filename); - }); - it('retrieves the data', async function () { - const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }); - expect(res.status).to.equal(200); - expect(res.data).to.eql(contents); - }); - }) - - describe("using cookie authentication", function () { - const filename = "roasted2.xar" - before(async function () { - await util.login() - }) - after(async function () { - await util.logout() - }) - - it('handles post of binary data', async function () { - const res = await util.axios.post('api/paths/' + filename, contents, { - headers: { 'Content-Type': 'application/octet-stream' } - }); - expect(res.status).to.equal(201); - expect(res.data).to.equal(dbUploadCollection + filename); - }); - it('retrieves the data', async function () { - const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }); - expect(res.status).to.equal(200); - expect(res.data).to.eql(contents); - }); - }) -}); - -describe("body with content-type application/xml", function () { - before(async function () { - await util.login() - }) - after(async function () { - await util.logout() - }) - - describe("with valid content", function () { - let uploadResponse - const filename = 'valid.xml' - const contents = Buffer.from('') - before(function () { - return util.axios.post('api/paths/' + filename, contents, { - headers: { 'Content-Type': 'application/xml' } - }) - .then(r => uploadResponse = r) - .catch(e => uploadResponse = e.response) - }) - it("is uploaded", function () { - expect(uploadResponse.status).to.equal(201) - expect(uploadResponse.data).to.equal(dbUploadCollection + filename) - }) - it('can be retrieved', async function () { - const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }); - expect(res.status).to.equal(200); - expect(res.data).to.eql(contents); - }); - }) - - describe("with invalid content", function () { - let uploadResponse - before(async function () { - return util.axios.post('api/paths/invalid.xml', Buffer.from('asdf'), { - headers: { 'Content-Type': 'application/xml' } - }) - .then(r => uploadResponse = r) - .catch(e => uploadResponse = e.response) - }) - it("is rejected as Bad Request", function () { - expect(uploadResponse.status).to.equal(400) - }) - it("with the correct error code", function () { - expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') - }) - it("with a human readable description", function () { - expect(uploadResponse.data.description).to.equal('Invalid XML') - }) - }) -}) - -describe("body with content-type application/json", function () { - before(async function () { - await util.login() - }) - after(async function () { - await util.logout() - }) - describe("with valid content", function () { - let uploadResponse - const filename = 'valid.json' - const contents = Buffer.from('{"valid":[]}') - before(function () { - return util.axios.post( - 'api/paths/' + filename, - contents, - { headers: { 'Content-Type': 'application/json'} } - ) - .then(r => uploadResponse = r) - .catch(e => uploadResponse = e.response) - }) - it("is uploaded", function () { - expect(uploadResponse.status).to.equal(201) - expect(uploadResponse.data).to.equal(dbUploadCollection + filename) - }) - it('can be retrieved', async function () { - const res = await util.axios.get('api/paths/' + filename, { responseType: 'arraybuffer' }); - expect(res.status).to.equal(200); - expect(res.data).to.eql(contents); - }); - }) - - describe("with invalid content", function () { - let uploadResponse - before(function () { - return util.axios.post( - 'api/paths/invalid.json', - '{"invalid: ()', - { headers: { 'Content-Type': 'application/json' } } - ) - .then(r => uploadResponse = r) - .catch(e => uploadResponse = e.response) - }) - it("is rejected as Bad Request", function () { - expect(uploadResponse.status).to.equal(400) - }) - it("with the correct error code", function () { - expect(uploadResponse.data.code).to.equal('errors:BODY_CONTENT_TYPE') - }) - it("with a human readable description", function () { - expect(uploadResponse.data.description).to.equal('Invalid JSON') - }) - }) -}) - describe('On Login', function () { let response @@ -308,18 +134,18 @@ describe('Query parameters', function () { headers: { "X-start": 22 } - }); - expect(res.status).to.equal(200); - expect(res.data.parameters.num).to.be.a('number'); - expect(res.data.parameters.num).to.equal(165.75); - expect(res.data.parameters.bool).to.be.a('boolean'); - expect(res.data.parameters.bool).to.be.true; - expect(res.data.parameters.int).to.be.a('number'); - expect(res.data.parameters.int).to.equal(776); - expect(res.data.parameters.string).to.equal('&a=22'); - expect(res.data.parameters.defaultParam).to.equal('abcdefg'); - expect(res.data.parameters['X-start']).to.equal(22); - }); + }) + expect(res.status).to.equal(200) + expect(res.data.parameters.num).to.be.a('number') + expect(res.data.parameters.num).to.equal(165.75) + expect(res.data.parameters.bool).to.be.a('boolean') + expect(res.data.parameters.bool).to.be.true + expect(res.data.parameters.int).to.be.a('number') + expect(res.data.parameters.int).to.equal(776) + expect(res.data.parameters.string).to.equal('&a=22') + expect(res.data.parameters.defaultParam).to.equal('abcdefg') + expect(res.data.parameters['X-start']).to.equal(22) + }) it('passes query parameters in POST', async function () { const res = await util.axios.request({ @@ -334,19 +160,19 @@ describe('Query parameters', function () { headers: { "X-start": 22 } - }); - expect(res.status).to.equal(200); - expect(res.data.method).to.equal('post'); - expect(res.data.parameters.num).to.be.a('number'); - expect(res.data.parameters.num).to.equal(165.75); - expect(res.data.parameters.bool).to.be.a('boolean'); - expect(res.data.parameters.bool).to.be.true; - expect(res.data.parameters.int).to.be.a('number'); - expect(res.data.parameters.int).to.equal(776); - expect(res.data.parameters.string).to.equal('&a=22'); - expect(res.data.parameters.defaultParam).to.equal('abcdefg'); - expect(res.data.parameters['X-start']).to.equal(22); - }); + }) + expect(res.status).to.equal(200) + expect(res.data.method).to.equal('post') + expect(res.data.parameters.num).to.be.a('number') + expect(res.data.parameters.num).to.equal(165.75) + expect(res.data.parameters.bool).to.be.a('boolean') + expect(res.data.parameters.bool).to.be.true + expect(res.data.parameters.int).to.be.a('number') + expect(res.data.parameters.int).to.equal(776) + expect(res.data.parameters.string).to.equal('&a=22') + expect(res.data.parameters.defaultParam).to.equal('abcdefg') + expect(res.data.parameters['X-start']).to.equal(22) + }) it('handles date parameters', async function () { const res = await util.axios.get('api/dates', { @@ -354,46 +180,8 @@ describe('Query parameters', function () { date: "2020-11-24Z", dateTime: "2020-11-24T20:22:41.975Z" } - }); - expect(res.status).to.equal(200); - expect(res.data).to.be.true; - }); -}); - -describe('Error reporting', function() { - it('receives error report', function() { - return util.axios.get('api/errors') - .catch(function(error) { - expect(error.response.status).to.equal(404); - expect(error.response.data.description).to.equal('document not found'); - expect(error.response.data.value).to.equal('error details'); - }); - }); - - it('receives dynamic XQuery error', function() { - return util.axios.post('api/errors') - .catch(function(error) { - expect(error.response.status).to.equal(500); - expect(error.response.data.line).to.match(/\d+/); - expect(error.response.data.description).to.contain('$undefined'); - }); - }); - - it('receives explicit error', function() { - return util.axios.delete('api/errors') - .catch(function(error) { - expect(error.response.status).to.equal(403); - expect(error.response.headers['content-type']).to.equal('application/xml'); - expect(error.response.data).to.equal(''); - }); - }); - - it('calls error handler', function() { - return util.axios.get('api/errors/handle') - .catch(function(error) { - expect(error.response.status).to.equal(500); - expect(error.response.headers['content-type']).to.equal('text/html'); - expect(error.response.data).to.contain('$undefined'); - }); - }); -}); + }) + expect(res.status).to.equal(200) + expect(res.data).to.be.true + }) +}) diff --git a/test/static.test.js b/test/static.test.js new file mode 100644 index 0000000..a859151 --- /dev/null +++ b/test/static.test.js @@ -0,0 +1,10 @@ +const util = require('./util.js') +const chai = require('chai') +const expect = chai.expect + +describe('Requesting a static file from the server', function () { + it('will download the resource', async function () { + const res = await util.axios.get('static/roaster-router-logo.png') + expect(res.status).to.equal(200) + }) +}) diff --git a/test/util.js b/test/util.js index 958c6ed..9e71c3f 100644 --- a/test/util.js +++ b/test/util.js @@ -1,24 +1,26 @@ -const chai = require('chai'); -const expect = chai.expect; -const axios = require('axios'); +const chai = require('chai') +const axios = require('axios') + +const path = require('path') +const chaiResponseValidator = require('chai-openapi-response-validator') +const spec = path.resolve("./test/app/api.json") +chai.use(chaiResponseValidator(spec)) + // read metadata from .existdb.json const existJSON = require('../.existdb.json') const serverInfo = existJSON.servers.localhost - const { origin } = new URL(serverInfo.server) -const app = `${origin}/exist/apps/roasted`; +const app = `${origin}/exist/apps/roasted` const axiosInstance = axios.create({ baseURL: app, - headers: { - "Origin": origin - }, + headers: { "Origin": origin }, withCredentials: true -}); +}) async function login() { - // console.log('Logging in ' + serverInfo.user + ' to ' + app); + // console.log('Logging in ' + serverInfo.user + ' to ' + app) const res = await axiosInstance.request({ url: 'login', method: 'post', @@ -26,20 +28,22 @@ async function login() { "user": serverInfo.user, "password": serverInfo.password } - }); + }) - // expect(res.status).to.equal(200); - // expect(res.data.user).to.equal(serverInfo.user); - - const cookie = res.headers["set-cookie"]; - axiosInstance.defaults.headers.Cookie = cookie[0]; - // console.log('Logged in as %s: %s', res.data.user, res.statusText); + const cookie = res.headers["set-cookie"] + axiosInstance.defaults.headers.Cookie = cookie[0] + // console.log('Logged in as %s: %s', res.data.user, res.statusText) } function logout() { - // console.log('Logging out ...'); - return axiosInstance.request({ url: 'logout', method: 'get'}) - .catch(_ => Promise.resolve()) + // console.log('Logging out ...') + return axiosInstance + .request({ url: 'logout', method: 'get'}) + .catch(_ => Promise.resolve()) } -module.exports = {axios: axiosInstance, login, logout}; +module.exports = { + axios: axiosInstance, + login, logout, + spec +}