|
1 | 1 | import { NextRequest } from "next/server" |
2 | | -import { isPageRoute, proxy } from "./proxy" |
| 2 | +import { isPageRoute, mitxonlineSurrogateKey, proxy } from "./proxy" |
3 | 3 |
|
4 | 4 | describe("isPageRoute", () => { |
5 | 5 | test.each([ |
@@ -33,19 +33,136 @@ describe("isPageRoute", () => { |
33 | 33 | }) |
34 | 34 | }) |
35 | 35 |
|
| 36 | +describe("mitxonlineSurrogateKey", () => { |
| 37 | + describe("course pages — /courses/:readable_id", () => { |
| 38 | + it("returns mitxonline:course key", () => { |
| 39 | + expect( |
| 40 | + mitxonlineSurrogateKey("/courses/course-v1:MITx+6.00.1x+3T2019"), |
| 41 | + ).toBe("mitxonline:course:course-v1:MITx+6.00.1x+3T2019") |
| 42 | + }) |
| 43 | + |
| 44 | + it("handles URL-encoded readable_id", () => { |
| 45 | + const encoded = encodeURIComponent("course-v1:MITx+6.00.1x+3T2019") |
| 46 | + expect(mitxonlineSurrogateKey(`/courses/${encoded}`)).toBe( |
| 47 | + "mitxonline:course:course-v1:MITx+6.00.1x+3T2019", |
| 48 | + ) |
| 49 | + }) |
| 50 | + }) |
| 51 | + |
| 52 | + describe("program pages — /programs/:readable_id and /courses/p/:readable_id", () => { |
| 53 | + it("returns mitxonline:program key for /programs/:readable_id", () => { |
| 54 | + expect(mitxonlineSurrogateKey("/programs/program-v1:MITx+SDS")).toBe( |
| 55 | + "mitxonline:program:program-v1:MITx+SDS", |
| 56 | + ) |
| 57 | + }) |
| 58 | + |
| 59 | + it("returns mitxonline:program key for /courses/p/:readable_id (ProgramAsCoursePage)", () => { |
| 60 | + // /courses/p/ renders ProgramAsCoursePage — the readable_id belongs to a |
| 61 | + // program, so the surrogate key must use the program namespace so that |
| 62 | + // MITxOnline's program-save signal purges this page correctly. |
| 63 | + expect(mitxonlineSurrogateKey("/courses/p/program-v1:MITx+SDS")).toBe( |
| 64 | + "mitxonline:program:program-v1:MITx+SDS", |
| 65 | + ) |
| 66 | + }) |
| 67 | + }) |
| 68 | + |
| 69 | + describe("non-product pages", () => { |
| 70 | + it.each(["/", "/search", "/about", "/courses", "/programs"])( |
| 71 | + "returns null for %s", |
| 72 | + (pathname) => { |
| 73 | + expect(mitxonlineSurrogateKey(pathname)).toBeNull() |
| 74 | + }, |
| 75 | + ) |
| 76 | + }) |
| 77 | + |
| 78 | + describe("hostile URL segments — regression: Headers.set() crash on control characters", () => { |
| 79 | + it("returns null without throwing on malformed percent-encoding in course path", () => { |
| 80 | + // %GG is not valid percent-encoding — decodeURIComponent would throw URIError |
| 81 | + expect(() => mitxonlineSurrogateKey("/courses/%GG")).not.toThrow() |
| 82 | + expect(mitxonlineSurrogateKey("/courses/%GG")).toBeNull() |
| 83 | + }) |
| 84 | + |
| 85 | + it("returns null without throwing on malformed percent-encoding in program path", () => { |
| 86 | + expect(() => mitxonlineSurrogateKey("/programs/%GG")).not.toThrow() |
| 87 | + expect(mitxonlineSurrogateKey("/programs/%GG")).toBeNull() |
| 88 | + }) |
| 89 | + |
| 90 | + it("returns null on CRLF in course path (%0D%0A decodes to \\r\\n)", () => { |
| 91 | + // Without safeDecodeSegment, passing this to Headers.set() would throw TypeError |
| 92 | + expect(() => |
| 93 | + mitxonlineSurrogateKey("/courses/foo%0D%0AX-Injected%3A+yes"), |
| 94 | + ).not.toThrow() |
| 95 | + expect( |
| 96 | + mitxonlineSurrogateKey("/courses/foo%0D%0AX-Injected%3A+yes"), |
| 97 | + ).toBeNull() |
| 98 | + }) |
| 99 | + |
| 100 | + it("returns null on CRLF in program path", () => { |
| 101 | + expect(() => |
| 102 | + mitxonlineSurrogateKey("/programs/foo%0D%0AX-Injected%3A+yes"), |
| 103 | + ).not.toThrow() |
| 104 | + expect( |
| 105 | + mitxonlineSurrogateKey("/programs/foo%0D%0AX-Injected%3A+yes"), |
| 106 | + ).toBeNull() |
| 107 | + }) |
| 108 | + |
| 109 | + it("returns null on null byte in course path", () => { |
| 110 | + expect(() => mitxonlineSurrogateKey("/courses/foo%00bar")).not.toThrow() |
| 111 | + expect(mitxonlineSurrogateKey("/courses/foo%00bar")).toBeNull() |
| 112 | + }) |
| 113 | + |
| 114 | + it("returns null on null byte in program path", () => { |
| 115 | + expect(() => mitxonlineSurrogateKey("/programs/foo%00bar")).not.toThrow() |
| 116 | + expect(mitxonlineSurrogateKey("/programs/foo%00bar")).toBeNull() |
| 117 | + }) |
| 118 | + }) |
| 119 | +}) |
| 120 | + |
36 | 121 | describe("proxy", () => { |
37 | 122 | const makeRequest = (pathname: string) => |
38 | 123 | new NextRequest(new URL(pathname, "https://learn.mit.edu")) |
39 | 124 |
|
40 | | - test("tags page routes with both Cache-Control and Surrogate-Key", () => { |
41 | | - const response = proxy(makeRequest("/courses/course-v1:MITxT+5.601x")) |
| 125 | + test("tags generic page routes with Cache-Control and html-pages Surrogate-Key", () => { |
| 126 | + const response = proxy(makeRequest("/about")) |
42 | 127 | expect(response.headers.get("Surrogate-Key")).toBe("html-pages") |
43 | 128 | expect(response.headers.get("Cache-Control")).toContain("s-maxage=") |
44 | 129 | }) |
45 | 130 |
|
| 131 | + test("appends per-item surrogate key for MITxOnline course pages", () => { |
| 132 | + const response = proxy(makeRequest("/courses/course-v1:MITxT+5.601x")) |
| 133 | + expect(response.headers.get("Surrogate-Key")).toBe( |
| 134 | + "html-pages mitxonline:course:course-v1:MITxT+5.601x", |
| 135 | + ) |
| 136 | + expect(response.headers.get("Cache-Control")).toContain("s-maxage=") |
| 137 | + }) |
| 138 | + |
| 139 | + test("appends per-item surrogate key for MITxOnline program pages (/programs/)", () => { |
| 140 | + const response = proxy(makeRequest("/programs/program-v1:MITxT+18.01x")) |
| 141 | + expect(response.headers.get("Surrogate-Key")).toBe( |
| 142 | + "html-pages mitxonline:program:program-v1:MITxT+18.01x", |
| 143 | + ) |
| 144 | + }) |
| 145 | + |
| 146 | + test("appends mitxonline:program surrogate key for /courses/p/ (ProgramAsCoursePage)", () => { |
| 147 | + const response = proxy(makeRequest("/courses/p/program-v1:MITxT+18.01x")) |
| 148 | + expect(response.headers.get("Surrogate-Key")).toBe( |
| 149 | + "html-pages mitxonline:program:program-v1:MITxT+18.01x", |
| 150 | + ) |
| 151 | + }) |
| 152 | + |
46 | 153 | test("leaves non-page routes untagged", () => { |
47 | 154 | const response = proxy(makeRequest("/healthcheck")) |
48 | 155 | expect(response.headers.get("Surrogate-Key")).toBeNull() |
49 | 156 | expect(response.headers.get("Cache-Control")).toBeNull() |
50 | 157 | }) |
| 158 | + |
| 159 | + test("does not throw on CRLF in course path and falls back to html-pages only", () => { |
| 160 | + // Regression: without safeDecodeSegment, Headers.set() would throw TypeError |
| 161 | + // when the decoded segment contains \r\n (%0D%0A). |
| 162 | + expect(() => |
| 163 | + proxy(makeRequest("/courses/foo%0D%0AX-Injected%3A+yes")), |
| 164 | + ).not.toThrow() |
| 165 | + const response = proxy(makeRequest("/courses/foo%0D%0AX-Injected%3A+yes")) |
| 166 | + expect(response.headers.get("Surrogate-Key")).toBe("html-pages") |
| 167 | + }) |
51 | 168 | }) |
0 commit comments