|
| 1 | +/** |
| 2 | + * 防御 i18n middleware 误吃 backend rewrite 路径 |
| 3 | + * |
| 4 | + * 历史背景(PR #335 hotfix): |
| 5 | + * i18n PR (#330) 改 proxy.ts 用 next-intl middleware 接管全站 locale |
| 6 | + * routing,但 matcher 的 negative-lookahead 排除组只列了 |
| 7 | + * `api|trpc|_next|_vercel|.*\..*`,漏掉了 next.config.mjs rewrites |
| 8 | + * 里非 /api/ 前缀的 backend proxy 路径(/auth, /oauth, /analytics)。 |
| 9 | + * 后果:用户访问 /oauth/render/github → 被 308 redirect 到 |
| 10 | + * /en/oauth/render/github → rewrite source 不匹配带 locale 的版本 → |
| 11 | + * 落到 [locale]/oauth/... 404 → 登录炸。 |
| 12 | + * |
| 13 | + * 这个测试就是防同样的事再发生:扫 next.config.mjs 里所有 rewrite |
| 14 | + * source,对每个 source 验证它的第一段路径在 proxy.ts matcher 的排除 |
| 15 | + * 组里。任何人加新 rewrite 但忘了改 matcher,CI 会 fail。 |
| 16 | + */ |
| 17 | + |
| 18 | +import { describe, expect, test } from "vitest"; |
| 19 | +import { readFileSync } from "node:fs"; |
| 20 | +import { join } from "node:path"; |
| 21 | + |
| 22 | +const ROOT = join(__dirname, ".."); |
| 23 | + |
| 24 | +/** |
| 25 | + * 静态扫 next.config.mjs,提取所有 rewrites() 函数体里的 source 列表。 |
| 26 | + * 不去 require/import 文件(它依赖 sentry wrap + env,加载麻烦), |
| 27 | + * 直接 regex 抓字面量。 |
| 28 | + */ |
| 29 | +function extractRewriteSources(): string[] { |
| 30 | + const content = readFileSync(join(ROOT, "next.config.mjs"), "utf-8"); |
| 31 | + // 提取 async rewrites() { ... } 函数体(非贪婪到下一个同级 async/closing) |
| 32 | + const match = content.match( |
| 33 | + /async rewrites\(\)[^{]*\{([\s\S]*?)^\s{2}\},?$/m, |
| 34 | + ); |
| 35 | + if (!match) { |
| 36 | + throw new Error( |
| 37 | + "无法定位 next.config.mjs 的 async rewrites() 函数体;正则可能要更新", |
| 38 | + ); |
| 39 | + } |
| 40 | + const body = match[1]; |
| 41 | + // 抓所有 source: "..." 的字面量(含跨行的 source: \n "...") |
| 42 | + const sources = [...body.matchAll(/source:\s*\n?\s*["']([^"']+)["']/g)].map( |
| 43 | + (m) => m[1], |
| 44 | + ); |
| 45 | + return sources; |
| 46 | +} |
| 47 | + |
| 48 | +/** |
| 49 | + * 解析 proxy.ts matcher 字符串,提取 negative-lookahead 里的排除项。 |
| 50 | + * 形如 "/((?!api|trpc|auth|oauth|...|_next|_vercel|.*\\..*).*)" |
| 51 | + * 返回 ['api', 'trpc', 'auth', 'oauth', ...] |
| 52 | + */ |
| 53 | +function extractMatcherExclusions(): string[] { |
| 54 | + const content = readFileSync(join(ROOT, "proxy.ts"), "utf-8"); |
| 55 | + // 找 matcher: "..." 字符串 |
| 56 | + const matcherMatch = content.match(/matcher:\s*["'`]([^"'`]+)["'`]/); |
| 57 | + if (!matcherMatch) { |
| 58 | + throw new Error("无法定位 proxy.ts 的 matcher 字段"); |
| 59 | + } |
| 60 | + const matcher = matcherMatch[1]; |
| 61 | + // 提取 (?!...) 里的内容 |
| 62 | + const lookaheadMatch = matcher.match(/\(\?!([^)]+)\)/); |
| 63 | + if (!lookaheadMatch) { |
| 64 | + throw new Error("matcher 不是 negative-lookahead 形式,测试需要改写"); |
| 65 | + } |
| 66 | + return lookaheadMatch[1].split("|").map((s) => s.replace(/\\/g, "")); |
| 67 | +} |
| 68 | + |
| 69 | +/** |
| 70 | + * 取 path 的第一个非空段。 |
| 71 | + * /oauth/render/github → 'oauth' |
| 72 | + * /auth/:path* → 'auth' |
| 73 | + * /api/admin/events → 'api' |
| 74 | + */ |
| 75 | +function firstSegment(pathLike: string): string { |
| 76 | + return pathLike.split("/").filter(Boolean)[0] ?? ""; |
| 77 | +} |
| 78 | + |
| 79 | +describe("proxy.ts matcher 必须排除所有 backend rewrite 路径", () => { |
| 80 | + const rewriteSources = extractRewriteSources(); |
| 81 | + const exclusions = extractMatcherExclusions(); |
| 82 | + |
| 83 | + test("能扫到 rewrite sources(防 regex 失效静默通过)", () => { |
| 84 | + expect(rewriteSources.length).toBeGreaterThan(0); |
| 85 | + }); |
| 86 | + |
| 87 | + test("matcher 形如 negative-lookahead,能解析出排除组", () => { |
| 88 | + expect(exclusions).toContain("api"); |
| 89 | + expect(exclusions).toContain("_next"); |
| 90 | + }); |
| 91 | + |
| 92 | + test.each(rewriteSources)( |
| 93 | + 'rewrite source "%s" 的第一段必须在 matcher 排除组里', |
| 94 | + (source) => { |
| 95 | + const seg = firstSegment(source); |
| 96 | + // 跳过参数段(如 :path* 不会作为 path 第一段,但兜底) |
| 97 | + if (!seg || seg.startsWith(":")) return; |
| 98 | + expect( |
| 99 | + exclusions, |
| 100 | + `加了新 rewrite 不带 /api/ 前缀的话,必须同步更新 proxy.ts 的 matcher 排除组。 |
| 101 | +当前缺少 "${seg}",否则 next-intl middleware 会把请求 redirect 到 /<locale>/${seg}/... |
| 102 | +导致 rewrite 不匹配,落到 [locale]/${seg}/... 404(参考 PR #335 登录炸事故)。`, |
| 103 | + ).toContain(seg); |
| 104 | + }, |
| 105 | + ); |
| 106 | +}); |
0 commit comments