|
| 1 | +import { flatRoutes } from "@remix-run/dev/dist/config/flat-routes.js"; |
| 2 | +import type { RouteManifest } from "@remix-run/dev/dist/config/routes.js"; |
| 3 | +import { matchPath } from "@remix-run/router"; |
| 4 | +import { existsSync, readdirSync, statSync } from "node:fs"; |
| 5 | +import { join } from "node:path"; |
| 6 | +import { describe, expect, it } from "vitest"; |
| 7 | +import { |
| 8 | + DEEPLINK_PATH_PREFIX, |
| 9 | + deeplinkSuffix, |
| 10 | + ENV_PAGE_TARGETS, |
| 11 | + resolveDeeplinkPage, |
| 12 | +} from "./deeplinkPages"; |
| 13 | + |
| 14 | +const APP_DIR = join(__dirname, ".."); |
| 15 | +const ROUTES_DIR = join(APP_DIR, "routes"); |
| 16 | + |
| 17 | +// The trailing dot excludes the environment layout route itself, which has no segment of its own. |
| 18 | +const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam."; |
| 19 | + |
| 20 | +// Route files that name no deeplink: the environment root, and Remix's layout-opt-out spelling. |
| 21 | +const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]); |
| 22 | + |
| 23 | +const PROBE = "probe_01ABC"; |
| 24 | + |
| 25 | +const DEEPLINK_ROUTE_FILE = "routes/[_].$.ts"; |
| 26 | + |
| 27 | +const compiledRoutes: RouteManifest = flatRoutes(APP_DIR, ["**/.*"]); |
| 28 | + |
| 29 | +function compiledUrl(id: string): string { |
| 30 | + if (!compiledRoutes[id]) throw new Error(`no compiled route with id ${id}`); |
| 31 | + |
| 32 | + const segments: string[] = []; |
| 33 | + let route = compiledRoutes[id]; |
| 34 | + while (route) { |
| 35 | + if (route.path) segments.unshift(route.path); |
| 36 | + route = route.parentId ? compiledRoutes[route.parentId] : undefined; |
| 37 | + } |
| 38 | + return `/${segments.join("/")}`; |
| 39 | +} |
| 40 | + |
| 41 | +const COMPILED_DEEPLINK_PATH = (() => { |
| 42 | + const entry = Object.values(compiledRoutes).find((route) => route.file === DEEPLINK_ROUTE_FILE); |
| 43 | + if (!entry) throw new Error(`${DEEPLINK_ROUTE_FILE} is not in the compiled route manifest`); |
| 44 | + return compiledUrl(entry.id).replace(/\/\*$/, ""); |
| 45 | +})(); |
| 46 | + |
| 47 | +const routeEntries = readdirSync(ROUTES_DIR); |
| 48 | + |
| 49 | +function isRouteModule(entry: string): boolean { |
| 50 | + const path = join(ROUTES_DIR, entry); |
| 51 | + if (!statSync(path).isDirectory()) return true; |
| 52 | + return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts")); |
| 53 | +} |
| 54 | + |
| 55 | +// A trailing `_` only opts out of the parent layout: `queues_.$queueParam` serves `/queues/{id}`. |
| 56 | +const envRoutes: string[][] = routeEntries |
| 57 | + .filter((entry) => entry.startsWith(ENV_ROUTE_PREFIX) && isRouteModule(entry)) |
| 58 | + .map((entry) => |
| 59 | + entry |
| 60 | + .slice(ENV_ROUTE_PREFIX.length) |
| 61 | + .replace(/\.(tsx|ts)$/, "") |
| 62 | + .split(".") |
| 63 | + ) |
| 64 | + .map((segments) => (segments.at(-1) === "_index" ? segments.slice(0, -1) : segments)) |
| 65 | + .map((segments) => segments.map((segment) => segment.replace(/_+$/, ""))); |
| 66 | + |
| 67 | +function routeMatches(path: string, { allowParams }: { allowParams: boolean }): boolean { |
| 68 | + const wanted = path === "" ? [] : path.split("/"); |
| 69 | + return envRoutes.some( |
| 70 | + (route) => |
| 71 | + route.length === wanted.length && |
| 72 | + route.every((segment, i) => (segment.startsWith("$") ? allowParams : segment === wanted[i])) |
| 73 | + ); |
| 74 | +} |
| 75 | + |
| 76 | +function envRouteSegments(): Set<string> { |
| 77 | + const segments = new Set<string>(); |
| 78 | + for (const entry of routeEntries) { |
| 79 | + if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue; |
| 80 | + const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0]; |
| 81 | + if (!segment || segment === "ts" || segment === "tsx") continue; |
| 82 | + segments.add(segment); |
| 83 | + } |
| 84 | + return segments; |
| 85 | +} |
| 86 | + |
| 87 | +function descendantsOf(prefix: string): string[][] { |
| 88 | + const depth = prefix === "" ? 0 : prefix.split("/").length; |
| 89 | + return envRoutes |
| 90 | + .filter((route) => route.length > depth && route.slice(0, depth).join("/") === prefix) |
| 91 | + .map((route) => |
| 92 | + route.slice(depth).map((segment) => (segment.startsWith("$") ? PROBE : segment)) |
| 93 | + ); |
| 94 | +} |
| 95 | + |
| 96 | +describe("deeplink targets", () => { |
| 97 | + it("read enough routes for the assertions below to mean anything", () => { |
| 98 | + expect(envRouteSegments().size).toBeGreaterThan(20); |
| 99 | + expect(envRoutes.length).toBeGreaterThan(40); |
| 100 | + }); |
| 101 | + |
| 102 | + it("every bare name lands on a real page that needs no id", () => { |
| 103 | + const broken = [...ENV_PAGE_TARGETS.entries()] |
| 104 | + .filter(([name]) => !routeMatches(resolveDeeplinkPage(name) ?? " ", { allowParams: false })) |
| 105 | + .map(([name, { landing }]) => `${name} -> ${landing || "(environment root)"}`); |
| 106 | + |
| 107 | + expect(broken).toEqual([]); |
| 108 | + }); |
| 109 | + |
| 110 | + it("every deep path lands on a real route, prefix graft included", () => { |
| 111 | + const broken: string[] = []; |
| 112 | + |
| 113 | + for (const [name, { prefix }] of ENV_PAGE_TARGETS) { |
| 114 | + for (const rest of descendantsOf(prefix)) { |
| 115 | + const suffix = [name, ...rest].join("/"); |
| 116 | + const resolved = resolveDeeplinkPage(suffix); |
| 117 | + if (!routeMatches(resolved ?? " ", { allowParams: true })) { |
| 118 | + broken.push(`${suffix} -> ${resolved}`); |
| 119 | + } |
| 120 | + } |
| 121 | + } |
| 122 | + |
| 123 | + expect(broken).toEqual([]); |
| 124 | + }); |
| 125 | + |
| 126 | + it("has deep paths worth checking", () => { |
| 127 | + expect(descendantsOf("waitpoints/tokens").length).toBeGreaterThan(0); |
| 128 | + expect(descendantsOf("tasks").length).toBeGreaterThan(2); |
| 129 | + expect(descendantsOf("runs").length).toBeGreaterThan(0); |
| 130 | + }); |
| 131 | + |
| 132 | + it("every environment page has a deeplink name", () => { |
| 133 | + const missing = [...envRouteSegments()] |
| 134 | + .filter((segment) => !NOT_DEEPLINK_NAMES.has(segment)) |
| 135 | + .filter( |
| 136 | + (segment) => routeMatches(segment, { allowParams: false }) && !ENV_PAGE_TARGETS.has(segment) |
| 137 | + ) |
| 138 | + .sort(); |
| 139 | + |
| 140 | + expect(missing).toEqual([]); |
| 141 | + }); |
| 142 | + |
| 143 | + it("points a 404ing name elsewhere, and gives a redirect shim no name at all", () => { |
| 144 | + for (const segment of ["tasks", "waitpoints", "metrics"]) { |
| 145 | + expect(routeMatches(segment, { allowParams: false })).toBe(false); |
| 146 | + } |
| 147 | + |
| 148 | + expect(ENV_PAGE_TARGETS.get("tasks")).toEqual({ landing: "", prefix: "tasks" }); |
| 149 | + expect(ENV_PAGE_TARGETS.get("waitpoints")).toEqual({ |
| 150 | + landing: "waitpoints/tokens", |
| 151 | + prefix: "waitpoints/tokens", |
| 152 | + }); |
| 153 | + expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false); |
| 154 | + }); |
| 155 | +}); |
| 156 | + |
| 157 | +describe("resolveDeeplinkPage", () => { |
| 158 | + it("maps a bare name to its landing page", () => { |
| 159 | + expect(resolveDeeplinkPage("apikeys")).toBe("apikeys"); |
| 160 | + expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens"); |
| 161 | + expect(resolveDeeplinkPage("tasks")).toBe(""); |
| 162 | + }); |
| 163 | + |
| 164 | + it("grafts deeper segments onto the prefix", () => { |
| 165 | + expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123"); |
| 166 | + expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task"); |
| 167 | + expect(resolveDeeplinkPage("waitpoints/waitpoint_123")).toBe("waitpoints/tokens/waitpoint_123"); |
| 168 | + }); |
| 169 | + |
| 170 | + it("does not duplicate a prefix the caller already wrote out", () => { |
| 171 | + expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens"); |
| 172 | + expect(resolveDeeplinkPage("waitpoints/tokens/waitpoint_123")).toBe( |
| 173 | + "waitpoints/tokens/waitpoint_123" |
| 174 | + ); |
| 175 | + }); |
| 176 | + |
| 177 | + it("rejects a name that is not a page", () => { |
| 178 | + expect(resolveDeeplinkPage("")).toBeUndefined(); |
| 179 | + expect(resolveDeeplinkPage("nonsense")).toBeUndefined(); |
| 180 | + expect(resolveDeeplinkPage("metrics")).toBeUndefined(); |
| 181 | + }); |
| 182 | + |
| 183 | + it("matches the page name whatever its case, and resolves it to the map's spelling", () => { |
| 184 | + expect(resolveDeeplinkPage("APIKeys")).toBe("apikeys"); |
| 185 | + expect(resolveDeeplinkPage("Waitpoints")).toBe("waitpoints/tokens"); |
| 186 | + expect(resolveDeeplinkPage("TASKS")).toBe(""); |
| 187 | + expect(resolveDeeplinkPage("Bulk-Actions")).toBe("bulk-actions"); |
| 188 | + expect(resolveDeeplinkPage("Nonsense")).toBeUndefined(); |
| 189 | + expect(resolveDeeplinkPage("Metrics")).toBeUndefined(); |
| 190 | + }); |
| 191 | + |
| 192 | + it("leaves the case of everything after the name alone", () => { |
| 193 | + expect(resolveDeeplinkPage("runs/run_ABC123")).toBe("runs/run_ABC123"); |
| 194 | + expect(resolveDeeplinkPage("Runs/run_ABC123")).toBe("runs/run_ABC123"); |
| 195 | + expect(resolveDeeplinkPage("TASKS/standard/My-Task")).toBe("tasks/standard/My-Task"); |
| 196 | + expect(resolveDeeplinkPage("Waitpoints/waitpoint_ABC")).toBe("waitpoints/tokens/waitpoint_ABC"); |
| 197 | + expect(resolveDeeplinkPage("Waitpoints/tokens/waitpoint_ABC")).toBe( |
| 198 | + "waitpoints/tokens/waitpoint_ABC" |
| 199 | + ); |
| 200 | + expect(resolveDeeplinkPage("Tasks/standard/Group%2FMy-Task")).toBe( |
| 201 | + "tasks/standard/Group%2FMy-Task" |
| 202 | + ); |
| 203 | + }); |
| 204 | + |
| 205 | + it("recognises a written-out prefix whatever its case, however many segments it spans", () => { |
| 206 | + expect(resolveDeeplinkPage("Waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123"); |
| 207 | + expect(resolveDeeplinkPage("waitpoints/Tokens/wp_123")).toBe("waitpoints/tokens/wp_123"); |
| 208 | + expect(resolveDeeplinkPage("WAITPOINTS/TOKENS/wp_123")).toBe("waitpoints/tokens/wp_123"); |
| 209 | + expect(resolveDeeplinkPage("Waitpoints/Tokens")).toBe("waitpoints/tokens"); |
| 210 | + }); |
| 211 | + |
| 212 | + it("holds for every multi-segment prefix in the map, not just waitpoints", () => { |
| 213 | + const multiSegment = [...ENV_PAGE_TARGETS.values()].filter(({ prefix }) => |
| 214 | + prefix.includes("/") |
| 215 | + ); |
| 216 | + |
| 217 | + expect(multiSegment.length).toBeGreaterThan(0); |
| 218 | + |
| 219 | + for (const { prefix } of multiSegment) { |
| 220 | + const shouted = prefix |
| 221 | + .split("/") |
| 222 | + .map((segment) => segment.toUpperCase()) |
| 223 | + .join("/"); |
| 224 | + expect(resolveDeeplinkPage(`${shouted}/${PROBE}`)).toBe(`${prefix}/${PROBE}`); |
| 225 | + expect(resolveDeeplinkPage(shouted)).toBe(prefix); |
| 226 | + } |
| 227 | + }); |
| 228 | + |
| 229 | + it("drops traversal segments, in plain and escaped spellings", () => { |
| 230 | + expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd"); |
| 231 | + expect(resolveDeeplinkPage("../runs")).toBe("runs"); |
| 232 | + expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1"); |
| 233 | + expect(resolveDeeplinkPage("runs/%2e%2e/%2E%2E/run_1")).toBe("runs/run_1"); |
| 234 | + expect(resolveDeeplinkPage("runs/%2e/run_1")).toBe("runs/run_1"); |
| 235 | + expect(resolveDeeplinkPage("runs/%ZZ/run_1")).toBe("runs/run_1"); |
| 236 | + }); |
| 237 | + |
| 238 | + it("passes encoded segments through without re-encoding them", () => { |
| 239 | + expect(resolveDeeplinkPage("tasks/standard/group%2Fmy-task")).toBe( |
| 240 | + "tasks/standard/group%2Fmy-task" |
| 241 | + ); |
| 242 | + expect(resolveDeeplinkPage("runs/a%3Fb%23c")).toBe("runs/a%3Fb%23c"); |
| 243 | + // The slash stays escaped, so this addresses one odd id rather than climbing out. |
| 244 | + expect(resolveDeeplinkPage("runs/..%2f..%2fetc")).toBe("runs/..%2f..%2fetc"); |
| 245 | + }); |
| 246 | +}); |
| 247 | + |
| 248 | +describe("the route Remix compiles from the filename", () => { |
| 249 | + it("mounts the deeplink route at /_ and nowhere else", () => { |
| 250 | + expect(COMPILED_DEEPLINK_PATH).toBe("/_"); |
| 251 | + expect(COMPILED_DEEPLINK_PATH).toBe(DEEPLINK_PATH_PREFIX); |
| 252 | + }); |
| 253 | + |
| 254 | + it("does not mount anything as a site-wide splat", () => { |
| 255 | + const siteWide = Object.values(compiledRoutes) |
| 256 | + .filter((route) => compiledUrl(route.id) === "/*") |
| 257 | + .map((route) => route.file); |
| 258 | + |
| 259 | + expect(siteWide).toEqual([]); |
| 260 | + }); |
| 261 | + |
| 262 | + it("compiled the manifest it is reading, paths and all", () => { |
| 263 | + expect(Object.keys(compiledRoutes).length).toBeGreaterThan(400); |
| 264 | + expect(compiledUrl("routes/login.magic")).toBe("/login/magic"); |
| 265 | + expect( |
| 266 | + compiledUrl( |
| 267 | + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.queues_.$queueParam" |
| 268 | + ) |
| 269 | + ).toBe("/orgs/:organizationSlug/projects/:projectParam/env/:envParam/queues/:queueParam"); |
| 270 | + }); |
| 271 | +}); |
| 272 | + |
| 273 | +describe("deeplinkSuffix", () => { |
| 274 | + it("strips the route's own prefix", () => { |
| 275 | + expect(deeplinkSuffix("/_/tasks")).toBe("tasks"); |
| 276 | + expect(deeplinkSuffix("/_/runs/run_123")).toBe("runs/run_123"); |
| 277 | + }); |
| 278 | + |
| 279 | + it("keeps an escaped slash intact, unlike the decoded splat param", () => { |
| 280 | + expect(deeplinkSuffix("/_/tasks/standard/group%2Fmy-task")).toBe( |
| 281 | + "tasks/standard/group%2Fmy-task" |
| 282 | + ); |
| 283 | + }); |
| 284 | + |
| 285 | + it("strips only the prefix, leaving the remainder's case alone", () => { |
| 286 | + expect(deeplinkSuffix("/_/runs/run_ABC123")).toBe("runs/run_ABC123"); |
| 287 | + expect(deeplinkSuffix("/_/tasks/standard/My-Task")).toBe("tasks/standard/My-Task"); |
| 288 | + }); |
| 289 | + |
| 290 | + it("matches the URL the router serves for it, splat case and all", () => { |
| 291 | + const route = `${COMPILED_DEEPLINK_PATH}/*`; |
| 292 | + expect(matchPath(route, "/_/apikeys")?.params["*"]).toBe("apikeys"); |
| 293 | + expect(matchPath(route, "/_/runs/run_123")?.params["*"]).toBe("runs/run_123"); |
| 294 | + expect(matchPath(route, "/_/APIKeys")?.params["*"]).toBe("APIKeys"); |
| 295 | + expect(matchPath(route, "/deeplink/apikeys")).toBeNull(); |
| 296 | + expect(matchPath(route, "/apikeys")).toBeNull(); |
| 297 | + }); |
| 298 | + |
| 299 | + it("treats a bare prefix, a trailing slash and anything outside it as no suffix", () => { |
| 300 | + expect(deeplinkSuffix("/_")).toBe(""); |
| 301 | + expect(deeplinkSuffix("/_/")).toBe(""); |
| 302 | + expect(deeplinkSuffix("/etc")).toBe(""); |
| 303 | + expect(deeplinkSuffix("/_app/orgs")).toBe(""); |
| 304 | + }); |
| 305 | + |
| 306 | + it("matches what the URL parser actually produces, keeping %2F and resolving %2e%2e", () => { |
| 307 | + const encodedSlash = new URL("http://x/_/tasks/standard/group%2Fmy-task"); |
| 308 | + expect(deeplinkSuffix(encodedSlash.pathname)).toBe("tasks/standard/group%2Fmy-task"); |
| 309 | + expect(resolveDeeplinkPage(deeplinkSuffix(encodedSlash.pathname))).toBe( |
| 310 | + "tasks/standard/group%2Fmy-task" |
| 311 | + ); |
| 312 | + |
| 313 | + const traversal = new URL("http://x/_/runs/%2e%2e/%2e%2e/etc"); |
| 314 | + expect(traversal.pathname).toBe("/etc"); |
| 315 | + expect(resolveDeeplinkPage(deeplinkSuffix(traversal.pathname))).toBeUndefined(); |
| 316 | + }); |
| 317 | +}); |
0 commit comments