diff --git a/assets/images/_fixtures/electrocat.png b/assets/images/_fixtures/electrocat.png new file mode 100644 index 000000000000..01dd7621acd5 Binary files /dev/null and b/assets/images/_fixtures/electrocat.png differ diff --git a/assets/images/_fixtures/screenshot.png b/assets/images/_fixtures/screenshot.png new file mode 100644 index 000000000000..cac7a023ed74 Binary files /dev/null and b/assets/images/_fixtures/screenshot.png differ diff --git a/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md b/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md index b2d18757aa90..715d4a62001c 100644 --- a/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md +++ b/content/admin/user-management/migrating-data-to-and-from-your-enterprise/exporting-migration-data-from-your-enterprise.md @@ -75,12 +75,6 @@ shortTitle: Export from your enterprise ``` Each time you add a new repository with an existing Migration GUID it will update the existing export. If you run `ghe-migrator add` again without a Migration GUID it will start a new export and generate a new Migration GUID. **Do not re-use the Migration GUID generated during an export when you start preparing your migration for import**. -3. If you locked the source repository, you can use the `ghe-migrator target_url` command to set a custom lock message on the repository page that links to the repository's new location. Pass the source repository URL, the target repository URL, and the Migration GUID from Step 5: - - ```shell - $ ghe-migrator target_url https://HOSTNAME/USERNAME/REPO-NAME https://TARGET-HOSTNAME/TARGET-USER-NAME/TARGET-REPO-NAME -g MIGRATION-GUID - ``` - 6. To add more repositories to the same export, use the `ghe-migrator add` command with the `-g` flag. You'll pass in the new repository URL and the Migration GUID from Step 5: ```shell $ ghe-migrator add https://HOSTNAME/USERNAME/OTHER-REPO-NAME -g MIGRATION-GUID --lock diff --git a/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md b/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md index ba602ec304c0..1fd2710bea3f 100644 --- a/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md +++ b/data/reusables/enterprise_site_admin_settings/3-7-new-subdomains.md @@ -4,11 +4,11 @@ {%- ifversion ghes = 3.5 or ghes = 3.6 %} -**Note**: The `http(s)://render.HOSTNAME` subdomain is deprecated in {% data variables.product.product_name %} 3.7 and later. After you upgrade to 3.7 or later, ensure that your TLS certificate covers the subdomains for the replacement services, `http(s)://notebook.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`. +**Note**: The `http(s)://render.HOSTNAME` subdomain is deprecated in {% data variables.product.product_name %} 3.7 and later. After you upgrade to 3.7 or later, ensure that your TLS certificate covers the subdomains for the replacement services, `http(s)://notebooks.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`. {%- elsif ghes = 3.7 or ghes = 3.8 %} -**Note**: The `http(s)://notebook.HOSTNAME` or `http(s)://viewscreen.HOSTNAME` subdomains are new in {% data variables.product.product_name %} 3.7 and later, and replace `http(s)://render.HOSTNAME`. After you upgrade to 3.7 or later, your TLS certificate must cover the subdomain for the replacement services, `http(s)://notebook.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`. +**Note**: The `http(s)://notebooks.HOSTNAME` or `http(s)://viewscreen.HOSTNAME` subdomains are new in {% data variables.product.product_name %} 3.7 and later, and replace `http(s)://render.HOSTNAME`. After you upgrade to 3.7 or later, your TLS certificate must cover the subdomain for the replacement services, `http(s)://notebooks.HOSTNAME` and `http(s)://viewscreen.HOSTNAME`. {%- endif %} diff --git a/lib/render-content/create-processor.js b/lib/render-content/create-processor.js index 21f968b6e971..f12806106f86 100644 --- a/lib/render-content/create-processor.js +++ b/lib/render-content/create-processor.js @@ -19,6 +19,7 @@ import remarkCodeExtra from 'remark-code-extra' import codeHeader from './plugins/code-header.js' import rewriteLocalLinks from './plugins/rewrite-local-links.js' import rewriteImgSources from './plugins/rewrite-asset-urls.js' +import rewriteAssetImgTags from './plugins/rewrite-asset-img-tags.js' import useEnglishHeadings from './plugins/use-english-headings.js' import wrapInElement from './plugins/wrap-in-element.js' import doctocatLinkIcon from './doctocat-link-icon.js' @@ -44,6 +45,7 @@ export default function createProcessor(context) { .use(raw) .use(wrapInElement, { selector: 'ol > li img', wrapper: 'span.procedural-image-wrapper' }) .use(rewriteImgSources) + .use(rewriteAssetImgTags) .use(rewriteLocalLinks, context) .use(html) } diff --git a/lib/render-content/plugins/rewrite-asset-img-tags.js b/lib/render-content/plugins/rewrite-asset-img-tags.js new file mode 100644 index 000000000000..e17f5fb33a8b --- /dev/null +++ b/lib/render-content/plugins/rewrite-asset-img-tags.js @@ -0,0 +1,125 @@ +import { visit, SKIP } from 'unist-util-visit' + +/** + * `structuredClone` was added in Node 17 and onwards. + * https://developer.mozilla.org/en-US/docs/Web/API/structuredClone#browser_compatibility + * + * At the time of writing, we use Node 18 in CI and in production, but + * someone might be previewing locally with an older version so + * let's make a quick polyfill. + * We could add a specific (`js-core`) package for this polyfill, but it's + * fortunately not necessary in this context because it's safe enough + * clone by turning into a string and back again. + */ +function structuredClonePolyfill(obj) { + if (typeof structuredClone !== 'undefined') { + return structuredClone(obj) + } else { + // Note, that this naive clone would turn Date objects into strings. + // So don't use this polyfill if certain values aren't primitives + // that JSON.parse can handle. + return JSON.parse(JSON.stringify(obj)) + } +} + +const SUPPORT_AVIF_ASSETS = Boolean(JSON.parse(process.env.SUPPORT_AVIF_ASSETS || 'false')) + +// This number must match a width we're willing to accept in a dynamic +// asset URL. +const MAX_WIDTH = 1000 + +// Matches any tags with an href that starts with `/assets/` +const matcher = (node) => + node.type === 'element' && + node.tagName === 'img' && + node.properties && + node.properties.src && + node.properties.src.startsWith('/assets/') + +/** + * Where it can mutate the AST to swap from: + * + * Alternative text + * + * To: + * + * + * + * [] + * Alternative text + * + * + * Note that the AVIF format is optional as it depends on the, off by + * default, `process.env.SUPPORT_AVIF_ASSETS`. + * */ +export default function rewriteAssetImgTags() { + return (tree) => { + visit(tree, matcher, (node) => { + if (node.properties.src.endsWith('.png')) { + const copyPNG = structuredClonePolyfill(node) + + /** + * If AVIF is support, we consider it "better" by injecting it first. + * If the user agent supports both WebP and AVIF and it gets + * HTML that looks like this: + * + * + * + * + * Alt text + * + * + * Then, browsers like Chrome will try the AVIF format first. + * Other browsers, that don't support AVIF will use WebP. + * */ + if (SUPPORT_AVIF_ASSETS) { + const sourceAVIF = { + type: 'element', + tagName: 'source', + properties: { + srcset: injectMaxWidth(node.properties.src.replace(/\.png$/, '.avif'), MAX_WIDTH), + type: 'image/avif', + }, + children: [], + } + node.children.push(sourceAVIF) + } + + const sourceWEBP = { + type: 'element', + tagName: 'source', + properties: { + srcset: injectMaxWidth(node.properties.src.replace(/\.png$/, '.webp'), MAX_WIDTH), + type: 'image/webp', + }, + children: [], + } + node.children.push(sourceWEBP) + + node.children.push(copyPNG) + node.tagName = 'picture' + delete node.properties.alt + delete node.properties.src + // Don't go further or else you end up in an infinite recursion + return SKIP + } + }) + } +} + +/** + * Given a pathname, insert the `/_mw-DDDD/`. + * + * For example, if the pathname is `/assets/cb-1234/images/foo.png` + * return `/assets/cb-1234/_mw-1000/images/foo.png` + */ +function injectMaxWidth(pathname, maxWidth) { + const split = pathname.split('/') + // This prefix needs to match what's possibly expected in dynamic-assets.js + const inject = `mw-${maxWidth}` + if (split.includes(inject)) { + throw new Error(`pathname already includes '${inject}'`) + } + split.splice(3, 0, inject) + return split.join('/') +} diff --git a/middleware/dynamic-assets.js b/middleware/dynamic-assets.js index 46ebccb0bc04..a5e84671335b 100644 --- a/middleware/dynamic-assets.js +++ b/middleware/dynamic-assets.js @@ -5,6 +5,35 @@ import sharp from 'sharp' import { assetCacheControl, defaultCacheControl } from './cache-control.js' import { setFastlySurrogateKey, SURROGATE_ENUMS } from './set-fastly-surrogate-key.js' +/** + * This is the indicator that is a virtual part of the URL. + * Similar to `/cb-1234/` in asset URLs, it's just there to tell the + * middleware that the image can be aggressively cached. It's not + * part of the actual file-on-disk path. + * Similarly, `/mw-1000/` is virtual and will be observed and removed from + * the pathname before trying to look it up as disk-on-file. + * The exact pattern needs to match how it's set in whatever Markdown + * processing code that might make dynamic asset URLs. + * So if you change this, make sure you change the code that expects + * to be able to inject this into the URL. + */ +const maxWidthPathPartRegex = /\/mw-(\d+)\// +/** + * + * Why not any free number? If we allowed it to be any integer number + * someone would put our backend servers at risk by doing something like: + * + * const makeURL = () => `${BASE}/assets/mw-${Math.floor(Math.random()*1000)}/foo.png` + * await Promise.all([...Array(10000).keys()].map(makeURL)) + * + * Which would be lots of distinctly different and valid URLs that the + * CDN can never really "protect us" on because they're too often distinct. + * + * At the moment, the only business need is for 1,000 pixels, so the array + * only has one. But can change in the future and make this sentence moot. + */ +const VALID_MAX_WIDTHS = [1000] + export default async function dynamicAssets(req, res, next) { if (!req.url.startsWith('/assets/')) return next() @@ -44,27 +73,38 @@ export default async function dynamicAssets(req, res, next) { return res.redirect(302, req.path) } + // From PNG to WEBP, if the PNG exists if (req.path.endsWith('.webp')) { - // From PNG (if it exists) to WEBP + const { url, maxWidth, error } = deconstructImageURL(req.path) + if (error) { + return res.status(400).type('text/plain').send(error.toString()) + } try { - const originalBuffer = await fs.readFile(req.path.slice(1).replace(/\.webp$/, '.png')) - const buffer = await sharp(originalBuffer) - // Note that by default, sharp will use a lossy compression. - // (i.e. `{lossless: false}` in the options) - // The difference is that a lossless image is slightly crisper - // but becomes on average 1.8x larger. - // Given how we serve images, no human would be able to tell the - // difference simply by looking at the image as it appears as an - // image tag in the web page. - // Also given that rendering-for-viewing is the "end of the line" - // for the image meaning it just ends up being viewed and not - // resaved as a source file. If we had intention to overwrite all - // original PNG source files to WEBP, we should consier lossless - // to preserve as much quality as possible at the source level. - // The default quality is 80% which, combined with `lossless:false` - // makes our images 2.8x smaller than the average PNG. - .webp() - .toBuffer() + const originalBuffer = await fs.readFile(url.slice(1).replace(/\.webp$/, '.png')) + const image = sharp(originalBuffer) + + if (maxWidth) { + const { width } = await image.metadata() + if (width > maxWidth) { + image.resize({ width: maxWidth }) + } + } + + // Note that by default, sharp will use a lossy compression. + // (i.e. `{lossless: false}` in the options) + // The difference is that a lossless image is slightly crisper + // but becomes on average 1.8x larger. + // Given how we serve images, no human would be able to tell the + // difference simply by looking at the image as it appears as an + // image tag in the web page. + // Also given that rendering-for-viewing is the "end of the line" + // for the image meaning it just ends up being viewed and not + // resaved as a source file. If we had intention to overwrite all + // original PNG source files to WEBP, we should consier lossless + // to preserve as much quality as possible at the source level. + // The default quality is 80% which, combined with `lossless:false` + // makes our images 2.8x smaller than the average PNG. + const buffer = await image.webp().toBuffer() assetCacheControl(res) return res.type('image/webp').send(buffer) } catch (error) { @@ -74,11 +114,23 @@ export default async function dynamicAssets(req, res, next) { } } + // From PNG to AVIF, if the PNG exists if (req.path.endsWith('.avif')) { - // From PNG (if it exists) to AVIF + const { url, maxWidth, error } = deconstructImageURL(req.path) + if (error) { + return res.status(400).type('text/plain').send(error.toString()) + } try { - const originalBuffer = await fs.readFile(req.path.slice(1).replace(/\.avif$/, '.png')) - const buffer = await sharp(originalBuffer) + const originalBuffer = await fs.readFile(url.slice(1).replace(/\.avif$/, '.png')) + const image = sharp(originalBuffer) + + if (maxWidth) { + const { width } = await image.metadata() + if (width > maxWidth) { + image.resize({ width: maxWidth }) + } + } + const buffer = await image .avif({ // The default is 4 (max is 9). Because this is a dynamic thing // and AVIF encoding is slow for large images, go for a smaller @@ -114,3 +166,19 @@ export default async function dynamicAssets(req, res, next) { // broken link, like it might be to a regular HTML page. res.status(404).type('text/plain').send('Asset not found') } + +function deconstructImageURL(url) { + let error + let maxWidth + const match = url.match(maxWidthPathPartRegex) + if (match) { + const [whole, number] = match + maxWidth = parseInt(number) + if (isNaN(maxWidth) || maxWidth <= 0 || !VALID_MAX_WIDTHS.includes(maxWidth)) { + error = new Error(`width number (${maxWidth}) is not a valid number`) + } else { + url = url.replace(whole, '/') + } + } + return { url, maxWidth, error } +} diff --git a/tests/fixtures/content/get-started/foo/index.md b/tests/fixtures/content/get-started/foo/index.md index c1879b3b87a8..3d0213d0d4e5 100644 --- a/tests/fixtures/content/get-started/foo/index.md +++ b/tests/fixtures/content/get-started/foo/index.md @@ -12,4 +12,5 @@ children: - /autotitling - /typo-autotitling - /cross-version-linking + - /single-image --- diff --git a/tests/fixtures/content/get-started/foo/single-image.md b/tests/fixtures/content/get-started/foo/single-image.md new file mode 100644 index 000000000000..e8676a84309d --- /dev/null +++ b/tests/fixtures/content/get-started/foo/single-image.md @@ -0,0 +1,14 @@ +--- +title: Single image +intro: A simple page that has 1 asset image +versions: + fpt: '*' + ghes: '*' + ghae: '*' + ghec: '*' +type: how_to +--- + +## An image + +![This is the alt text](/assets/images/_fixtures/screenshot.png) diff --git a/tests/helpers/e2etest.js b/tests/helpers/e2etest.js index 6f36cb9db93a..7ed20e5eda5f 100644 --- a/tests/helpers/e2etest.js +++ b/tests/helpers/e2etest.js @@ -10,6 +10,7 @@ export async function get( followRedirects: false, followAllRedirects: false, headers: {}, + responseType: undefined, } ) { const method = opts.method || 'get' @@ -23,6 +24,7 @@ export async function get( retry: { limit: 0 }, throwHttpErrors: false, followRedirect: opts.followAllRedirects || opts.followRedirects, + responseType: opts.responseType, }, isUndefined ) diff --git a/tests/rendering-fixtures/dynamic-assets.js b/tests/rendering-fixtures/dynamic-assets.js new file mode 100644 index 000000000000..4fd6cca5fdea --- /dev/null +++ b/tests/rendering-fixtures/dynamic-assets.js @@ -0,0 +1,124 @@ +import { jest } from '@jest/globals' +import sharp from 'sharp' +import { fileTypeFromBuffer } from 'file-type' + +import { SURROGATE_ENUMS } from '../../middleware/set-fastly-surrogate-key.js' +import { get, head } from '../helpers/e2etest.js' + +const POSSIBLE_EXTENSIONS = ['webp', 'avif'] + +const EXPECTED_MIMETYPES = { + webp: 'image/webp', + avif: 'image/avif', +} + +describe('dynamic assets', () => { + jest.setTimeout(3 * 60 * 1000) + + test.each(POSSIBLE_EXTENSIONS)('GET PNG as a %s', async (extension) => { + const res = await get(`/assets/images/_fixtures/screenshot.${extension}`, { + responseType: 'buffer', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) + const { mime } = await fileTypeFromBuffer(res.text) + if (extension === 'webp') { + expect(mime).toBe('image/webp') + } else if (extension === 'avif') { + expect(mime).toBe('image/avif') + } else { + throw new Error('unrecognized test') + } + }) + + test.each(POSSIBLE_EXTENSIONS)('HEAD PNG as a %s', async (extension) => { + const res = await head(`/assets/images/_fixtures/screenshot.${extension}`) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) + }) + + test.each(POSSIBLE_EXTENSIONS)('get PNG as a %s with cache busting prefix', async (extension) => { + const res = await get(`/assets/cb-12345/images/_fixtures/screenshot.${extension}`) + expect(res.statusCode).toBe(200) + expect(res.headers['cache-control']).toContain('public') + expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) + expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL) + }) + + test.each(POSSIBLE_EXTENSIONS)('max-width=1000 as a %s', async (extension) => { + // The _fixtures/screenshot.png is 2000(x1494) which is *more than 1000* + const res = await get(`/assets/images/mw-1000/_fixtures/screenshot.${extension}`, { + responseType: 'buffer', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) + const image = sharp(res.text) + const { width, height } = await image.metadata() + expect(width).toBe(1000) + expect(height).toBe(747) + }) + + test.each(POSSIBLE_EXTENSIONS)('max-width not necessary as a %s', async (extension) => { + // The _fixtures/electrocat.png is 448(x448) which is *less than 1000* + const res = await get(`/assets/images/mw-1000/_fixtures/electrocat.${extension}`, { + responseType: 'buffer', + }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) + const image = sharp(res.text) + const { width, height } = await image.metadata() + expect(width).toBe(448) + expect(height).toBe(448) + }) + + test("can't set set max-width indicator on the PNG that is already on disk", async () => { + const res = await get(`/assets/images/mw-1000/_fixtures/screenshot.png`) + expect(res.statusCode).toBe(404) + }) + + test.each(POSSIBLE_EXTENSIONS)( + 'max-width has to be a valid number when converting to %s', + async (extension) => { + // 0 is too small + { + const res = await get(`/assets/images/mw-0/_fixtures/screenshot.${extension}`) + expect(res.statusCode).toBe(400) + expect(res.headers['content-type']).toMatch('text/plain') + expect(res.text).toMatch('Error: width number (0) is not a valid number') + } + // 1234 is not a number that is recognized + { + const res = await get(`/assets/images/mw-1234/_fixtures/screenshot.${extension}`) + expect(res.statusCode).toBe(400) + expect(res.headers['content-type']).toMatch('text/plain') + expect(res.text).toMatch('Error: width number (1234) is not a valid number') + } + } + ) + + test('unrecognized extensions get a 404', async () => { + const res = await get('/assets/images/_fixtures/screenshot.xxx') + expect(res.statusCode).toBe(404) + expect(res.headers['content-type']).toMatch(/text\/plain/) + }) + + test('unrecognized as source PNG get a 404', async () => { + const res = await get('/assets/images/_fixtures/sceeeeenshoot.png') + expect(res.statusCode).toBe(404) + expect(res.headers['content-type']).toMatch(/text\/plain/) + }) + + test('recognized extensions but no equivalent .png get a 404', async () => { + const res = await get('/assets/images/_fixtures/neverheardof.webp') + expect(res.statusCode).toBe(404) + expect(res.headers['content-type']).toMatch(/text\/plain/) + }) + + test.each(['key', 'key=value'])('any query string (%p) triggers a redirect', async (qs) => { + const res = await get('/assets/images/_fixtures/screenshot.webp?' + qs) + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe('/assets/images/_fixtures/screenshot.webp') + expect(res.headers['cache-control']).toContain('public') + expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) + }) +}) diff --git a/tests/rendering-fixtures/images.js b/tests/rendering-fixtures/images.js new file mode 100644 index 000000000000..98de501a7730 --- /dev/null +++ b/tests/rendering-fixtures/images.js @@ -0,0 +1,44 @@ +import sharp from 'sharp' + +import { get, getDOM } from '../helpers/e2etest.js' + +describe('render Markdown image tags', () => { + test('page with a single image', async () => { + const $ = await getDOM('/get-started/foo/single-image') + + const pictures = $('#article-contents picture') + expect(pictures.length).toBe(1) + + const sources = $('source', pictures) + // Note: We might support AVIF too at some point, then + // this test needs to change. + expect(sources.length).toBe(1) + const srcset = sources.attr('srcset') + expect(srcset).toBe('/assets/cb-914945/mw-1000/images/_fixtures/screenshot.webp') + const type = sources.attr('type') + expect(type).toBe('image/webp') + + const imgs = $('img', pictures) + expect(imgs.length).toBe(1) + const src = imgs.attr('src') + expect(src).toBe('/assets/cb-914945/images/_fixtures/screenshot.png') + const alt = imgs.attr('alt') + expect(alt).toBe('This is the alt text') + + const res = await get(srcset, { responseType: 'buffer' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toBe('image/webp') + + // The fixture image `_fixtures/screenshot.png` is known to be very + // large. Larger than 1,000 pixels wide. + // When transformed as a source in a `` tag, it's automatically + // injected with the `mw-XXXXX` virtual indicator in the URL that + // resizes it on-the-fly. + const image = sharp(res.text) + const { width, height } = await image.metadata() + expect(width).toBe(1000) + // The `_fixtures/screenshot.png` is 2000x1494. + // So if 2000/1494==1000/x, then x becomes 1494*1000/2000=747 + expect(height).toBe(747) + }) +}) diff --git a/tests/rendering/dynamic-assets.js b/tests/rendering/dynamic-assets.js deleted file mode 100644 index 4cdfe12475f9..000000000000 --- a/tests/rendering/dynamic-assets.js +++ /dev/null @@ -1,61 +0,0 @@ -import { jest } from '@jest/globals' - -import { SURROGATE_ENUMS } from '../../middleware/set-fastly-surrogate-key.js' -import { get, head } from '../helpers/e2etest.js' - -const POSSIBLE_EXTENSIONS = ['webp', 'avif'] - -const EXPECTED_MIMETYPES = { - webp: 'image/webp', - avif: 'image/avif', -} - -describe('dynamic assets', () => { - jest.setTimeout(3 * 60 * 1000) - - test.each(POSSIBLE_EXTENSIONS)('GET logo PNG as a %s', async (extension) => { - const res = await get(`/assets/images/site/logo.${extension}`) - expect(res.statusCode).toBe(200) - expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) - }) - - test.each(POSSIBLE_EXTENSIONS)('HEAD logo PNG as a %s', async (extension) => { - const res = await head(`/assets/images/site/logo.${extension}`) - expect(res.statusCode).toBe(200) - expect(res.headers['content-type']).toBe(EXPECTED_MIMETYPES[extension]) - }) - - test.each(POSSIBLE_EXTENSIONS)('get PNG as a %s with cache busting prefix', async (extension) => { - const res = await get(`/assets/cb-12345/images/site/logo.${extension}`) - expect(res.statusCode).toBe(200) - expect(res.headers['cache-control']).toContain('public') - expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) - expect(res.headers['surrogate-key']).toBe(SURROGATE_ENUMS.MANUAL) - }) - - test('unrecognized extensions get a 404', async () => { - const res = await get('/assets/images/site/logo.xxx') - expect(res.statusCode).toBe(404) - expect(res.headers['content-type']).toMatch(/text\/plain/) - }) - - test('unrecognized as source PNG get a 404', async () => { - const res = await get('/assets/images/site/loooogo.png') - expect(res.statusCode).toBe(404) - expect(res.headers['content-type']).toMatch(/text\/plain/) - }) - - test('recognized extensions but no equivalent .png get a 404', async () => { - const res = await get('/assets/images/site/neverheardof.webp') - expect(res.statusCode).toBe(404) - expect(res.headers['content-type']).toMatch(/text\/plain/) - }) - - test.each(['key', 'key=value'])('any query string (%p) triggers a redirect', async (qs) => { - const res = await get('/assets/images/site/logo.webp?' + qs) - expect(res.statusCode).toBe(302) - expect(res.headers.location).toBe('/assets/images/site/logo.webp') - expect(res.headers['cache-control']).toContain('public') - expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) - }) -})