Skip to content

Commit fa37603

Browse files
authored
Make redirect_from safe when old and new pages coexist (#62757)
1 parent 5a630d8 commit fa37603

3 files changed

Lines changed: 93 additions & 19 deletions

File tree

src/redirects/lib/precompile.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,16 @@ export async function precompileRedirects(pageList: Page[]): Promise<Redirects>
2626
Object.assign(allRedirects, page.buildRedirects())
2727
}
2828

29+
// Remove any redirect whose source URL is also a real page permalink.
30+
// This prevents redirect_from entries from clobbering live pages when a
31+
// new page (versioned broadly) declares a redirect_from that overlaps
32+
// with an older page that still exists in some versions.
33+
for (const page of pageList.filter((xpage) => xpage.languageCode === 'en')) {
34+
for (const permalink of page.permalinks) {
35+
delete allRedirects[permalink.hrefWithoutLanguage]
36+
}
37+
}
38+
2939
// NOTE: Exception redirects **MUST COME AFTER** pageList redirects above in order
3040
// to properly override them. Exception redirects are unicorn one-offs that are not
3141
// otherwise handled by the versionless redirect fallbacks (see lib/all-versions.ts).

src/redirects/tests/content/redirect-orphans.ts

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,39 +3,24 @@ import path from 'path'
33
import { describe, expect, test, vi } from 'vitest'
44

55
import { loadPages } from '@/frame/lib/page-data'
6-
import Permalink from '@/frame/lib/permalink'
76

87
describe('redirect orphans', () => {
98
// Because calling `loadPages` will trigger a warmup, this can potentially
109
// be very slow in CI. So we need a timeout.
1110
vi.setConfig({ testTimeout: 60 * 1000 })
1211

13-
test('no page is a redirect in another file', async () => {
12+
test('no redirect_from entry has a trailing slash', async () => {
1413
// Only doing English because they're the only files we do PRs for.
1514
const pageList = await loadPages(undefined, ['en'])
1615

17-
const redirectFroms = new Map()
16+
const errors = []
1817
for (const page of pageList) {
1918
for (const redirectFrom of page.redirect_from || []) {
2019
if (redirectFrom.endsWith('/') && redirectFrom.startsWith('/')) {
21-
throw new Error(
22-
`In ${path.join(
23-
'content',
24-
page.relativePath,
25-
)} redirect entry (${redirectFrom}) has a trailing slash`,
20+
errors.push(
21+
`In ${path.join('content', page.relativePath)} redirect entry (${redirectFrom}) has a trailing slash`,
2622
)
2723
}
28-
redirectFroms.set(redirectFrom, page.relativePath)
29-
}
30-
}
31-
32-
const errors = []
33-
for (const page of pageList) {
34-
const asPath = Permalink.relativePathToSuffix(page.relativePath)
35-
if (redirectFroms.has(asPath)) {
36-
errors.push(
37-
`${asPath} is a redirect_from in ${path.join('content', redirectFroms.get(asPath))}`,
38-
)
3924
}
4025
}
4126
expect(errors.length, errors.join('\n')).toBe(0)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
3+
import type { Page } from '@/types'
4+
5+
vi.mock('@/frame/lib/read-json-file', () => ({
6+
readCompressedJsonFileFallback: () => ({}),
7+
}))
8+
9+
vi.mock('../../lib/exception-redirects', () => ({
10+
default: () => ({}),
11+
}))
12+
13+
const { default: precompileRedirects } = await import('../../lib/precompile')
14+
const { default: generateRedirectsForPermalinks } = await import('../../lib/permalinks')
15+
16+
// Minimal stand-in for a Page instance. precompileRedirects() only relies on
17+
// `languageCode`, `permalinks`, and `buildRedirects()`.
18+
function makePage(
19+
languageCode: string,
20+
permalinks: { pageVersion: string; hrefWithoutLanguage: string }[],
21+
redirectFrom: string[],
22+
): Page {
23+
const fullPermalinks = permalinks.map((permalink) => ({
24+
languageCode,
25+
title: 'Title',
26+
href: `/${languageCode}${permalink.hrefWithoutLanguage}`,
27+
...permalink,
28+
}))
29+
return {
30+
languageCode,
31+
permalinks: fullPermalinks,
32+
redirect_from: redirectFrom,
33+
buildRedirects: () => generateRedirectsForPermalinks(fullPermalinks, redirectFrom),
34+
} as unknown as Page
35+
}
36+
37+
describe('precompileRedirects', () => {
38+
test('removes a redirect_from-generated redirect that clobbers a live old-page permalink, but keeps it for versions where the old page is absent', async () => {
39+
// The old page only exists in GHES 3.14.
40+
const oldPage = makePage(
41+
'en',
42+
[
43+
{
44+
pageVersion: 'enterprise-server@3.14',
45+
hrefWithoutLanguage: '/enterprise-server@3.14/foo',
46+
},
47+
],
48+
[],
49+
)
50+
51+
// The replacement page exists in both 3.14 and 3.15, and declares
52+
// `redirect_from: ['/foo']`, which would otherwise clobber the old
53+
// page's live permalink in 3.14.
54+
const newPage = makePage(
55+
'en',
56+
[
57+
{
58+
pageVersion: 'enterprise-server@3.14',
59+
hrefWithoutLanguage: '/enterprise-server@3.14/bar',
60+
},
61+
{
62+
pageVersion: 'enterprise-server@3.15',
63+
hrefWithoutLanguage: '/enterprise-server@3.15/bar',
64+
},
65+
],
66+
['/foo'],
67+
)
68+
69+
const redirects = await precompileRedirects([oldPage, newPage])
70+
71+
// The old page's live permalink in 3.14 must not be clobbered by the
72+
// replacement page's redirect_from.
73+
expect(redirects['/enterprise-server@3.14/foo']).toBeUndefined()
74+
75+
// But in 3.15, where the old page doesn't exist, the redirect must
76+
// still be there.
77+
expect(redirects['/enterprise-server@3.15/foo']).toBe('/enterprise-server@3.15/bar')
78+
})
79+
})

0 commit comments

Comments
 (0)