| | import fs from 'fs' |
| | import path from 'path' |
| |
|
| | import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' |
| | import nock from 'nock' |
| | import type { Response } from 'express' |
| |
|
| | import { get } from '@/tests/helpers/e2etest' |
| | import { checkCachingHeaders } from '@/tests/helpers/caching-headers' |
| | import { setDefaultFastlySurrogateKey } from '@/frame/middleware/set-fastly-surrogate-key' |
| | import archivedEnterpriseVersionsAssets from '@/archives/middleware/archived-enterprise-versions-assets' |
| | import type { ExtendedRequest } from '@/types' |
| |
|
| | function getNextStaticAsset(directory: string) { |
| | const root = path.join('.next', 'static', directory) |
| | const files = fs.readdirSync(root) |
| | if (!files.length) throw new Error(`Can't find any files in ${root}`) |
| | return path.join(root, files[0]) |
| | } |
| |
|
| | function mockRequest(requestPath: string, { headers }: { headers?: Record<string, string> } = {}) { |
| | const _headers = Object.fromEntries( |
| | Object.entries(headers || {}).map(([key, value]) => [key.toLowerCase(), value]), |
| | ) |
| | return { |
| | path: requestPath, |
| | url: requestPath, |
| | get: (header: string) => { |
| | return _headers[header.toLowerCase()] |
| | }, |
| | set: (header: string, value: string) => { |
| | _headers[header.toLowerCase()] = value |
| | }, |
| | headers, |
| | } |
| | } |
| |
|
| | type MockResponse = { |
| | status: number | undefined |
| | statusCode: number | undefined |
| | json?: (payload: unknown) => void |
| | send?: (body: unknown) => void |
| | sendStatus?: (statusCode: number) => void |
| | end?: () => void |
| | _json?: string |
| | _send?: string |
| | headers: Record<string, string> |
| | set?: (key: string | object, value: string) => void |
| | removeHeader?: (key: string) => void |
| | hasHeader?: (key: string) => boolean |
| | } |
| |
|
| | const mockResponse = () => { |
| | const res: MockResponse = { |
| | status: undefined, |
| | statusCode: undefined, |
| | headers: {}, |
| | } |
| | res.json = (payload) => { |
| | res._json = payload as string |
| | } |
| | res.send = (body) => { |
| | res.status = 200 |
| | res.statusCode = 200 |
| | res._send = body as string |
| | } |
| | res.end = () => { |
| | |
| | } |
| | res.sendStatus = (statusCode) => { |
| | res.status = statusCode |
| | res.statusCode = statusCode |
| | |
| | } |
| | res.set = (key, value) => { |
| | if (typeof key === 'string') { |
| | res.headers[key.toLowerCase()] = value |
| | } else { |
| | for (const [k, v] of Object.entries(key)) { |
| | res.headers[k.toLowerCase()] = v |
| | } |
| | } |
| | } |
| | res.removeHeader = (key) => { |
| | delete res.headers[key] |
| | } |
| | res.hasHeader = (key) => { |
| | return key in res.headers |
| | } |
| | |
| | ;(res as unknown as { status: (code: number) => MockResponse }).status = (code: number) => { |
| | res.status = code |
| | res.statusCode = code |
| | return res |
| | } |
| | return res |
| | } |
| |
|
| | describe('static assets', () => { |
| | vi.setConfig({ testTimeout: 60 * 1000 }) |
| |
|
| | test('should serve /assets/cb-* with optimal headers', async () => { |
| | const res = await get('/assets/cb-1234/images/site/logo.png') |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res) |
| | }) |
| |
|
| | test('should serve /assets/ with optimal headers', async () => { |
| | const res = await get('/assets/images/site/logo.png') |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res, true) |
| | }) |
| |
|
| | test('should serve /_next/static/ with optimal headers', async () => { |
| | |
| | |
| | const filePath = getNextStaticAsset('css') |
| | const asURL = `/${filePath.replace('.next', '_next').split(path.sep).join('/')}` |
| | const res = await get(asURL) |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res) |
| | }) |
| |
|
| | test('should 404 on /assets/cb-* with plain text', async () => { |
| | const res = await get('/assets/cb-1234/never/heard/of.png') |
| | expect(res.statusCode).toBe(404) |
| | expect(res.headers['content-type']).toContain('text/plain') |
| | |
| | checkCachingHeaders(res, true, 60) |
| | }) |
| | test('should 404 on /assets/ with plain text', async () => { |
| | const res = await get('/assets/never/heard/of.png') |
| | expect(res.statusCode).toBe(404) |
| | expect(res.headers['content-type']).toContain('text/plain') |
| | checkCachingHeaders(res, true, 60) |
| | }) |
| | test('should 404 on /_next/static/ with plain text', async () => { |
| | const res = await get('/_next/static/never/heard/of.css') |
| | expect(res.statusCode).toBe(404) |
| | expect(res.headers['content-type']).toContain('text/plain') |
| | checkCachingHeaders(res, true, 60) |
| | }) |
| | test("should redirect if the URL isn't all lowercase", async () => { |
| | |
| | { |
| | const res = await get('/assets/images/SITE/logo.png') |
| | expect(res.statusCode).toBe(302) |
| | expect(res.headers.location).toBe('/assets/images/site/logo.png') |
| | } |
| | |
| | { |
| | const res = await get('/assets/images/site/LoGo.png') |
| | expect(res.statusCode).toBe(302) |
| | expect(res.headers.location).toBe('/assets/images/site/logo.png') |
| | } |
| | |
| | { |
| | const res = await get('/assets/images/site/logo.PNG') |
| | expect(res.statusCode).toBe(302) |
| | expect(res.headers.location).toBe('/assets/images/site/logo.png') |
| | } |
| | }) |
| | }) |
| |
|
| | describe('archived enterprise static assets', () => { |
| | |
| | |
| |
|
| | vi.setConfig({ testTimeout: 60 * 1000 }) |
| |
|
| | beforeAll(async () => { |
| | |
| | |
| | |
| | |
| |
|
| | const sampleCSS = '/* nice CSS */' |
| |
|
| | nock('https://github.github.com') |
| | .get('/docs-ghes-2.21/_next/static/foo.css') |
| | .reply(200, sampleCSS, { |
| | 'content-type': 'text/css', |
| | 'content-length': `${sampleCSS.length}`, |
| | }) |
| | nock('https://github.github.com') |
| | .get('/docs-ghes-2.21/_next/static/only-on-proxy.css') |
| | .reply(200, sampleCSS, { |
| | 'content-type': 'text/css', |
| | 'content-length': `${sampleCSS.length}`, |
| | }) |
| | nock('https://github.github.com') |
| | .get('/docs-ghes-2.3/_next/static/only-on-2.3.css') |
| | .reply(200, sampleCSS, { |
| | 'content-type': 'text/css', |
| | 'content-length': `${sampleCSS.length}`, |
| | }) |
| | nock('https://github.github.com') |
| | .get('/docs-ghes-2.3/_next/static/fourofour.css') |
| | .reply(404, 'not found', { |
| | 'content-type': 'text/plain', |
| | }) |
| | nock('https://github.github.com') |
| | .get('/docs-ghes-3.5/assets/images/some-image.png') |
| | .reply(404, 'not found', { |
| | 'content-type': 'text/plain', |
| | }) |
| | nock('https://github.github.com') |
| | .get('/docs-ghes-2.3/assets/images/site/logo.png') |
| | .reply(404, 'Not found', { |
| | 'content-type': 'text/plain', |
| | }) |
| | }) |
| |
|
| | afterAll(() => nock.cleanAll()) |
| |
|
| | test('should proxy if the static asset is prefixed', async () => { |
| | const req = mockRequest('/enterprise/2.21/_next/static/foo.css', { |
| | headers: { |
| | Referrer: '/enterprise/2.21', |
| | }, |
| | }) |
| | const res = mockResponse() |
| | const next = () => { |
| | throw new Error('did not expect this to ever happen') |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res, false, 60) |
| | }) |
| |
|
| | test('should proxy if the Referrer header indicates so on home page', async () => { |
| | const req = mockRequest('/_next/static/only-on-proxy.css', { |
| | headers: { |
| | Referrer: '/enterprise/2.21', |
| | }, |
| | }) |
| | const res = mockResponse() |
| | const next = () => { |
| | throw new Error('did not expect this to ever happen') |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res, false, 60) |
| | }) |
| |
|
| | test('should proxy if the Referrer header indicates so on sub-page', async () => { |
| | const req = mockRequest('/_next/static/only-on-2.3.css', { |
| | headers: { |
| | Referrer: '/en/enterprise-server@2.3/some/page', |
| | }, |
| | }) |
| | const res = mockResponse() |
| | const next = () => { |
| | throw new Error('did not expect this to ever happen') |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | expect(res.statusCode).toBe(200) |
| | checkCachingHeaders(res, false, 60) |
| | }) |
| |
|
| | test('might still 404 even with the right referrer', async () => { |
| | const req = mockRequest('/_next/static/fourofour.css', { |
| | headers: { |
| | Referrer: '/en/enterprise-server@2.3/some/page', |
| | }, |
| | }) |
| | const res = mockResponse() |
| | let nexted = false |
| | const next = () => { |
| | nexted = true |
| | } |
| | setDefaultFastlySurrogateKey(req, res, next) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | |
| | |
| | expect(nexted).toBe(true) |
| | }) |
| |
|
| | test('404 on the proxy but actually present here', async () => { |
| | const req = mockRequest('/assets/images/site/logo.png', { |
| | headers: { |
| | Referrer: '/en/enterprise-server@2.3/some/page', |
| | }, |
| | }) |
| | const res = mockResponse() |
| | let nexted = false |
| | const next = () => { |
| | nexted = true |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | |
| | |
| | expect(nexted).toBe(true) |
| | }) |
| |
|
| | describe.each([ |
| | { |
| | name: 'Next.js chunk assets from archived enterprise referrer (legacy format)', |
| | path: '/_next/static/chunks/9589-81283b60820a85f5.js', |
| | referrer: '/en/enterprise/3.5/authentication/connecting-to-github-with-ssh', |
| | expectStatus: 204, |
| | shouldCallNext: false, |
| | }, |
| | { |
| | name: 'Next.js chunk assets from archived enterprise referrer (new format)', |
| | path: '/_next/static/chunks/pages/[versionId]-40812da083876691.js', |
| | referrer: '/en/enterprise-server@3.5/authentication/connecting-to-github-with-ssh', |
| | expectStatus: 204, |
| | shouldCallNext: false, |
| | }, |
| | { |
| | name: 'Next.js build manifest from archived enterprise referrer', |
| | path: '/_next/static/NkhGE2zLVuDHVh7pXdtVC/_buildManifest.js', |
| | referrer: '/enterprise-server@3.5/admin/configuration', |
| | expectStatus: 204, |
| | shouldCallNext: false, |
| | }, |
| | ])( |
| | 'should return $expectStatus for $name', |
| | ({ name, path: testPath, referrer, expectStatus, shouldCallNext }) => { |
| | test(name, async () => { |
| | const req = mockRequest(testPath, { |
| | headers: { |
| | Referrer: referrer, |
| | }, |
| | }) |
| | const res = mockResponse() |
| | let nexted = false |
| | const next = () => { |
| | if (!shouldCallNext) { |
| | throw new Error('should not call next() for suppressed assets') |
| | } |
| | nexted = true |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | expect(res.statusCode).toBe(expectStatus) |
| | if (shouldCallNext) { |
| | expect(nexted).toBe(true) |
| | } |
| | }) |
| | }, |
| | ) |
| |
|
| | describe.each([ |
| | { |
| | name: 'Next.js assets from non-enterprise referrer', |
| | path: '/_next/static/chunks/main-abc123.js', |
| | referrer: '/en/actions/using-workflows', |
| | expectStatus: undefined, |
| | shouldCallNext: true, |
| | }, |
| | { |
| | name: 'non-Next.js assets from archived enterprise referrer', |
| | path: '/assets/images/some-image.png', |
| | referrer: '/en/enterprise-server@3.5/some/page', |
| | expectStatus: undefined, |
| | shouldCallNext: true, |
| | }, |
| | ])( |
| | 'should not suppress $name', |
| | ({ name, path: testPath, referrer, expectStatus, shouldCallNext }) => { |
| | test(name, async () => { |
| | const req = mockRequest(testPath, { |
| | headers: { |
| | Referrer: referrer, |
| | }, |
| | }) |
| | const res = mockResponse() |
| | let nexted = false |
| | const next = () => { |
| | nexted = true |
| | } |
| | setDefaultFastlySurrogateKey(req, res, () => {}) |
| | await archivedEnterpriseVersionsAssets( |
| | req as unknown as ExtendedRequest, |
| | res as unknown as Response, |
| | next, |
| | ) |
| | expect(nexted).toBe(shouldCallNext) |
| | expect(res.statusCode).toBe(expectStatus) |
| | }) |
| | }, |
| | ) |
| | }) |
| |
|