diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index a485cb6..e661396 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - node-version: [12.x, 14.x, 16.x, 18.x, 20.x] + node-version: [12, 14, 16, 18, 20] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} @@ -18,4 +18,8 @@ jobs: run: | sudo apt-get update sudo apt-get install -y build-essential libcairo2-dev libgif-dev libpango1.0-dev - - run: cd "$GITHUB_WORKSPACE"/export-server && npm install + - name: Install NPM packages + run: cd "$GITHUB_WORKSPACE"/export-server && npm install + - name: Tests + run: cd "$GITHUB_WORKSPACE"/export-server && ./test.sh + if: matrix.node-version >= 18 diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 26279d9..2318222 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -37,6 +37,7 @@ node_18: script: - cd ./export-server - npm install + - ./test.sh node_20: image: node:20-slim @@ -47,3 +48,4 @@ node_20: script: - cd ./export-server - npm install + - ./test.sh diff --git a/export-server/test.sh b/export-server/test.sh new file mode 100755 index 0000000..3130341 --- /dev/null +++ b/export-server/test.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +# Start the server. +npm run start & +# Give server some time to start completely. +sleep 3 +SERVER_PID=$(pgrep node) +echo "Server process id is $SERVER_PID." + +# Run actual tests. +node --test +EXIT_CODE=$? + +# Stop / kill server process. +kill $SERVER_PID +if [ $? -ne 0 ] +then + kill -9 $SERVER_PID +fi + +sleep 1 + +if [ $EXIT_CODE -eq 0 ] +then + echo Tests were successful. + exit 0 +else + echo Tests have failed. + exit 1 +fi diff --git a/export-server/tests/server.test.js b/export-server/tests/server.test.js new file mode 100644 index 0000000..5634c62 --- /dev/null +++ b/export-server/tests/server.test.js @@ -0,0 +1,336 @@ +const assert = require('node:assert'); +const { spawn, spawnSync } = require('node:child_process'); +const http = require('node:http'); +const { after, before, describe, it } = require('node:test'); + +describe('server', () => { + describe('request to non-root URL is not acceptable', () => { + it('HTTP status code 404 when another URL is requested', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + path: '/foo.svg' + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(404, response.statusCode); + }); + }); + }); + + describe('only POST method is allowed', () => { + it('HTTP status code 405 when DELETE is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'DELETE', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + + it('HTTP status code 405 when GET is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'GET', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + + it('HTTP status code 405 when HEAD is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'HEAD', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + + it('HTTP status code 405 when PATCH is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'PATCH', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + + it('HTTP status code 405 when PUT is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'PUT', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + + it('HTTP status code 405 when TRACE is used', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'TRACE', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(405, response.statusCode); + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + }); + + describe('query available methods via OPTIONS', () => { + it('HTTP status code 204 and Allow header for OPTIONS', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'OPTIONS', + }; + + const req = http.request(options); + req.end(); + + req.on('response', (response) => { + // 204: no content + assert.strictEqual(204, response.statusCode); + // Response shall contain header "Allow: POST". + assert.ok(response.headers['allow']); + assert.strictEqual(response.headers['allow'], 'POST'); + }); + }); + }); + + describe('large payload is rejected', () => { + it('HTTP status code 413 when request is too large', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + }; + + const req = http.request(options); + const payload = { long: 'abcdefghij'.repeat(500000) }; + req.write(JSON.stringify(payload)); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(413, response.statusCode); + }); + }); + }); + + describe('malformed JSON is rejected', () => { + it('HTTP status code 400 when request contains invalid JSON', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + }; + + const req = http.request(options); + const payload = '{ "foo": "bar", "baz": '; + req.write(payload); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(400, response.statusCode); + }); + }); + }); + + describe('image generation requests', () => { + const example_data = { + title: { + text: "ECharts entry example" + }, + tooltip: {}, + legend: { + data: ["Sales"] + }, + backgroundColor: "#ffffff", + xAxis: { + data: ["shirt","cardigan","chiffon shirt","pants","heels","socks"] + }, + yAxis: {}, + series: [{ + name: "Sales", + type: "bar", + data: [5,20,36,10,10,20] + }] + }; + const payload = JSON.stringify(example_data); + + it('request with example data is successful', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + headers: { + 'X-Image-Format': 'svg' + } + }; + + const req = http.request(options); + req.write(payload); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(200, response.statusCode); + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + assert.ok(body.startsWith('')); + assert.ok(body.indexOf('width="700"') > 0); + assert.ok(body.indexOf('height="400"') > 0); + assert.ok(body.indexOf('ECharts entry example') > 0); + assert.ok(body.indexOf('cardigan') > 0); + assert.ok(body.indexOf('chiffon shirt') > 0); + assert.ok(body.indexOf('pants') > 0); + assert.ok(body.indexOf('heels') > 0); + assert.ok(body.indexOf('socks') > 0); + }); + }); + }); + + it('request with different image width', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + headers: { + 'X-Image-Format': 'svg', + 'X-Image-Width': '751' + } + }; + + const req = http.request(options); + req.write(payload); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(200, response.statusCode); + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + assert.ok(body.startsWith('')); + assert.ok(body.indexOf('width="751"') > 0); + assert.ok(body.indexOf('height="400"') > 0); + }); + }); + }); + + it('request with different image height', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + headers: { + 'X-Image-Format': 'svg', + 'X-Image-Height': '432' + } + }; + + const req = http.request(options); + req.write(payload); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(200, response.statusCode); + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + assert.ok(body.startsWith('')); + assert.ok(body.indexOf('width="700"') > 0); + assert.ok(body.indexOf('height="432"') > 0); + }); + }); + }); + + it('request with different image width and height', () => { + const options = { + port: 3000, + host: 'localhost', + method: 'POST', + headers: { + 'X-Image-Format': 'svg', + 'X-Image-Width': '765', + 'X-Image-Height': '456' + } + }; + + const req = http.request(options); + req.write(payload); + req.end(); + + req.on('response', (response) => { + assert.strictEqual(200, response.statusCode); + let body = ''; + response.on('data', (chunk) => { + body += chunk; + }); + response.on('end', () => { + assert.ok(body.startsWith('')); + assert.ok(body.indexOf('width="765"') > 0); + assert.ok(body.indexOf('height="456"') > 0); + }); + }); + }); + }); +});