diff --git a/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts b/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts new file mode 100644 index 0000000000..5c591bdda4 --- /dev/null +++ b/plugins/techdocs-backend/src/cache/cacheMiddleware.test.ts @@ -0,0 +1,142 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { createCacheMiddleware, TechDocsCache } from '.'; + +/** + * Mocks cached HTTP response. + */ +const getMockHttpResponseFor = (content: string): Buffer => { + return Buffer.concat([ + Buffer.from(`HTTP/1.1 200 OK +Content-Type: text/plain; charset=utf-8 +Accept-Ranges: bytes +Cache-Control: public, max-age=0 +Last-Modified: Sat, 1 Jul 2021 12:00:00 GMT +Date: Sat, 1 Jul 2021 12:00:00 GMT +Connection: close +Content-Length: ${content.length}\n\n`), + Buffer.from(content), + ]); +}; + +/** + * Wait for the socket to close. Works because, above, we set connection: close + */ +const waitForSocketClose = () => new Promise(resolve => setTimeout(resolve, 0)); + +describe('createCacheMiddleware', () => { + let cache: jest.Mocked; + let app: express.Express; + + beforeEach(async () => { + cache = ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), + invalidateMultiple: jest.fn().mockResolvedValue(undefined), + } as unknown) as jest.Mocked; + const router = await createCacheMiddleware({ + logger: getVoidLogger(), + cache, + }); + app = express().use(router); + app.use((req, res, next) => { + // By default, send cacheable content. + if (req.path !== '/api/static/docs/error.png') { + res.send('default-response'); + } else { + next(new Error()); + } + }); + }); + + describe('invalidate', () => { + it('responds with 400 when no objects are provided', async () => { + const response = await request(app).post('/cache/invalidate'); + expect(response.status).toBe(400); + }); + + it('responds with 500 if invalidation throws', async () => { + cache.invalidateMultiple.mockRejectedValueOnce(new Error()); + const response = await request(app) + .post('/cache/invalidate') + .set('Content-Type', 'application/json') + .send({ + objects: ['one/index.html', 'two/index.html'], + }); + + expect(response.status).toBe(500); + }); + + it('responds with 204 if invalidation succeeds', async () => { + const expectedObjects = ['one/index.html', 'two/index.html']; + cache.invalidateMultiple.mockResolvedValue([]); + await request(app) + .post('/cache/invalidate') + .set('Content-Type', 'application/json') + .send({ + objects: expectedObjects, + }) + .expect(204); + + expect(cache.invalidateMultiple).toHaveBeenCalledWith(expectedObjects); + }); + }); + + describe('middleware', () => { + it('does not apply to non-static/docs paths', async () => { + await request(app) + .get('/api/static/not-docs') + .expect(200, 'default-response'); + + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('responds with cached response', async () => { + cache.get.mockResolvedValueOnce(getMockHttpResponseFor('xyz')); + + await request(app).get('/api/static/docs/foo.html').expect(200, 'xyz'); + + await waitForSocketClose(); + expect(cache.set).not.toHaveBeenCalled(); + }); + + it('sets cache when content is cacheable', async () => { + const expectedPath = 'default/api/xyz/index.html'; + await request(app) + .get(`/api/static/docs/${expectedPath}`) + .expect(200, 'default-response'); + + await waitForSocketClose(); + expect(cache.set).toHaveBeenCalled(); + + const [actualPath, actualBuffer] = (cache.set as jest.Mock).mock.calls[0]; + expect(actualPath).toBe(expectedPath); + expect(actualBuffer.toString()).toContain('default-response'); + }); + + it('does not set cache on error', async () => { + await request(app).get('/api/static/docs/error.png').expect(500); + + await waitForSocketClose(); + expect(cache.set).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/techdocs-backend/src/cache/cacheMiddleware.ts b/plugins/techdocs-backend/src/cache/cacheMiddleware.ts index 0c06052ba4..c25d002181 100644 --- a/plugins/techdocs-backend/src/cache/cacheMiddleware.ts +++ b/plugins/techdocs-backend/src/cache/cacheMiddleware.ts @@ -114,8 +114,10 @@ export const createCacheMiddleware = ({ // When a socket is closed, if there were no errors and the data written // over the socket should be cached, cache it! socket.on('close', hadError => { - if (writeToCache && !hadError) { - cache.set(reqPath, Buffer.concat(chunks)); + const content = Buffer.concat(chunks); + const head = content.toString('utf8', 0, 12); + if (writeToCache && !hadError && head === 'HTTP/1.1 200') { + cache.set(reqPath, content); } });