/** * @copyright Copyright (c) Open-Xchange GmbH, Germany <info@open-xchange.com> * @license AGPL-3.0 * * This code is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with OX App Suite. If not, see <https://www.gnu.org/licenses/agpl-3.0.txt>. * * Any use of the work other than as authorized under this license or copyright law is prohibited. */ import { injectApp, mockRedis, mockConfig, mockFetch } from './util.js' import fs from 'fs' import { expect } from 'chai' import * as td from 'testdouble' import RedisMock from 'ioredis-mock' import sinon from 'sinon' import zlib from 'node:zlib' const image = fs.readFileSync('./spec/media/image.png') const imageStat = fs.statSync('./spec/media/image.png') const sandbox = sinon.createSandbox() describe('File caching service', function () { let app let redis beforeEach(async function () { await mockConfig({ baseUrls: ['http://ui-server/'] }) redis = await mockRedis() mockFetch({ 'http://ui-server': {} }) await Promise.all([ redis.client.set('ui-middleware:versionInfo', JSON.stringify({ version: 554855300 })), redis.client.set('ui-middleware:554855300:viteManifests', JSON.stringify({})), redis.client.set('ui-middleware:554855300:oxManifests:body', '{}'), redis.client.set('ui-middleware:554855300:oxManifests:meta', JSON.stringify({ headers: { 'content-type': 'application/json' } })), redis.client.set('ui-middleware:554855300:/example.js:body', 'this is example'), redis.client.set('ui-middleware:554855300:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } })), redis.client.set('ui-middleware:554855300:/test.txt:body', 'this is test'), redis.client.set('ui-middleware:554855300:/test.txt:meta', JSON.stringify({ headers: { 'content-type': 'text/plain' } })), redis.client.set('ui-middleware:554855300:/index.html:body', '<html><head></head><body>it\'s me</body></html>'), redis.client.set('ui-middleware:554855300:/index.html:meta', JSON.stringify({ headers: { 'content-type': 'text/html' } })), redis.client.set('ui-middleware:554855300:/main.css:body', 'body { color: red; }'), redis.client.set('ui-middleware:554855300:/main.css:meta', JSON.stringify({ headers: { 'content-type': 'text/css' } })), redis.client.set('ui-middleware:554855300:/image.png:body', image), redis.client.set('ui-middleware:554855300:/image.png:meta', JSON.stringify({ headers: { 'content-type': 'image/png' } })) ]) app = await injectApp() }) afterEach(async function () { await new RedisMock().flushdb() td.reset() }) it('serves files defined in manifest.json file', async function () { const response = await app.inject({ url: '/example.js' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('application/javascript') expect(response.body).to.equal('this is example') // expect(response.headers['content-security-policy']).to.contain('sha256-NzZhMTE2Njc2YTgyNTZmZTdlZGVjZDU3YTNmYzRjNmM1OWZkMTI2NjRkYzZmMWM3YTkwMGU3ZTdhNDlhZmVlMwo=') const response2 = await app.inject({ url: '/test.txt' }) expect(response2.statusCode).to.equal(200) expect(response2.headers['content-type']).to.equal('text/plain') expect(response2.body).to.equal('this is test') }) it('serves css files', async function () { const response = await app.inject({ url: '/main.css' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('text/css') // expect(response.headers['content-security-policy']).to.contain('sha256-YjRiYWRlYTVhYmM5ZTZkNjE2ZGM4YjcwZWRlNzUxMmU0YjgxY2UxMWExOTI2ZjM1NzM1M2Y2MWJjNmUwMmZjMwo=') }) it('serves / as index.html', async function () { const response = await app.inject({ url: '/' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-type']).to.equal('text/html') expect(response.body).to.equal('<html><head></head><body>it\'s me</body></html>') }) it('returns 404 if file can not be resolved', async function () { const response = await app.inject({ url: '/unknown-file.txt' }) expect(response.statusCode).to.equal(404) }) it('serves binary files', async function () { const response = await app.inject({ url: '/image.png' }) expect(response.statusCode).to.equal(200) expect(response.body.length === imageStat.size) expect(response.rawPayload).to.deep.equal(image) }) it('only fetches files once', async function () { const spy = sandbox.spy(redis.client, 'get') expect(spy.callCount).to.equal(0) let response = await app.inject({ url: '/example.js' }) expect(response.statusCode).to.equal(200) expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1) response = await app.inject({ url: '/example.js' }) expect(response.statusCode).to.equal(200) expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1) }) it('delivers binary files from cache', async function () { const spy = sandbox.spy(redis.client, 'get') expect(spy.callCount).to.equal(0) let response = await app.inject({ url: '/image.png' }) expect(response.statusCode).to.equal(200) expect(response.rawPayload).to.deep.equal(image) expect(spy.withArgs(sinon.match(/\/image.png/)).callCount).to.equal(1) response = await app.inject({ url: '/image.png' }) expect(response.statusCode).to.equal(200) expect(response.rawPayload).to.deep.equal(image) expect(spy.withArgs(sinon.match(/\/image.png/)).callCount).to.equal(1) }) it('serves cached files with version', async function () { redis.client.set('ui-middleware:1234:/example.js:body', 'first') redis.client.set('ui-middleware:1234:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } })) const response1 = await app.inject({ url: '/example.js', headers: { version: '1234' } }) expect(response1.statusCode).to.equal(200) expect(response1.body).to.equal('first') const response2 = await app.inject({ url: '/example.js' }) expect(response2.statusCode).to.equal(200) expect(response2.body).to.equal('this is example') const latestVersion = response1.headers['latest-version'] const response3 = await app.inject({ url: '/example.js', headers: { version: latestVersion } }) expect(response3.statusCode).to.equal(200) expect(response3.body).to.equal('this is example') }) it('checks again for files after an error occurred', async function () { redis.client.set('ui-middleware:123:viteManifests', JSON.stringify({})) const response1 = await app.inject({ url: '/example.js', headers: { version: '123' } }) expect(response1.statusCode).to.equal(404) redis.client.set('ui-middleware:123:/example.js:body', 'Now available') redis.client.set('ui-middleware:123:/example.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript' } })) const response2 = await app.inject({ url: '/example.js', headers: { version: '123' } }) expect(response2.statusCode).to.equal(200) expect(response2.body).to.equal('Now available') }) it('only fetches files once, even when requested simultanously', async function () { const spy = sandbox.spy(redis.client, 'get') expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ app.inject({ url: '/example.js' }), app.inject({ url: '/example.js' }) ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.withArgs(sinon.match(/\/example.js/)).callCount).to.equal(1) }) it('only fetches manifests once, even when requested simultaneously', async function () { const spy = sandbox.spy(redis.client, 'get') expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ app.inject({ url: '/manifests' }), app.inject({ url: '/example.js' }) ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.withArgs(sinon.match(/oxManifests/)).callCount).to.equal(1) }) describe('redis request latency', function () { beforeEach(function () { // overwrite redis.get to simulate network latency const get = redis.client.get redis.client.get = function () { return new Promise(resolve => { setTimeout(async () => { resolve(await get.apply(redis.client, arguments)) }, 20) }) } }) it('only requests files once, even though the response takes some time', async function () { const spy = sandbox.spy(redis.client, 'get') expect(spy.callCount).to.equal(0) const [res1, res2] = await Promise.all([ app.inject({ url: '/example.js' }), app.inject({ url: '/example.js' }) ]) expect(res1.statusCode).to.equal(200) expect(res2.statusCode).to.equal(200) expect(spy.callCount).to.equal(1) }) }) it('serves index.html gzip compressed', async function () { redis.client.set('ui-middleware:554855300:/index.html:body', zlib.gzipSync('<html><head></head><body>it\'s me</body></html>')) redis.client.set('ui-middleware:554855300:/index.html:meta', JSON.stringify({ headers: { 'content-type': 'text/html', 'content-encoding': 'gzip' } })) const response = await app.inject({ url: '/index.html' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-encoding']).to.equal('gzip') // check for files in redis expect(zlib.gunzipSync(await redis.client.getBuffer('ui-middleware:554855300:/index.html:body')).toString()).to.equal(zlib.gunzipSync(response.rawPayload).toString()) }) it('serves files as brotli compressed', async function () { redis.client.set('ui-middleware:554855300:/large.js:body', zlib.brotliCompressSync('this is example')) redis.client.set('ui-middleware:554855300:/large.js:meta', JSON.stringify({ headers: { 'content-type': 'application/javascript', 'content-encoding': 'br' } })) const response = await app.inject({ url: '/large.js' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-encoding']).to.equal('br') // check for files in redis expect((await redis.client.getBuffer('ui-middleware:554855300:/large.js:body')).toString()).to.equal(response.body) }) it('does not serve small files with compression', async function () { redis.client.set('ui-middleware:554855300:/small.js:body', 'this is example') redis.client.set('ui-middleware:554855300:/small.js:meta', JSON.stringify({ headers: { 'content-type': 'text/plain' } })) const response = await app.inject({ url: '/small.js' }) expect(response.statusCode).to.equal(200) expect(response.headers).to.not.have.property('content-encoding') // check for files in redis expect((await redis.client.getBuffer('ui-middleware:554855300:/small.js:body')).toString()).to.equal(response.body) }) it('does not serve other mime types with compression', async function () { redis.client.set('ui-middleware:554855300:/file.mp3:body', 'this is example') redis.client.set('ui-middleware:554855300:/file.mp3:meta', JSON.stringify({ headers: { 'content-type': 'audio/mpeg' } })) const response = await app.inject({ url: '/file.mp3' }) expect(response.statusCode).to.equal(200) expect(response.headers).to.not.have.property('content-encoding') // check for files in redis expect(await redis.client.getBuffer('ui-middleware:554855300:/file.mp3:body')).to.deep.equal(response.rawPayload) }) it('does serve svg with brotli compression (also escapes chars in regex)', async function () { redis.client.set('ui-middleware:554855300:/file.svg:body', zlib.brotliCompressSync([...new Array(2500)].join(' '))) redis.client.set('ui-middleware:554855300:/file.svg:meta', JSON.stringify({ headers: { 'content-type': 'image/svg+xml', 'content-encoding': 'br' } })) const response = await app.inject({ url: '/file.svg' }) expect(response.statusCode).to.equal(200) expect(response.headers['content-encoding']).to.equal('br') // check for files in redis expect(await redis.client.getBuffer('ui-middleware:554855300:/file.svg:body')).to.deep.equal(response.rawPayload) }) it('does not fetch from origins not defined in baseUrls', async function () { const response = await app.inject({ url: '//t989be0.netlify.app/xss.html' }) expect(response.statusCode).to.equal(400) }) })