|
1 | | -import { readdirSync } from "node:fs"; |
| 1 | +import { existsSync, readdirSync, statSync } from "node:fs"; |
2 | 2 | import { join } from "node:path"; |
3 | 3 | import { describe, expect, it } from "vitest"; |
4 | | -import { ENV_PAGE_SEGMENTS } from "./deeplinkPages"; |
| 4 | +import { ENV_PAGE_TARGETS, resolveDeeplinkPage } from "./deeplinkPages"; |
| 5 | + |
| 6 | +const ROUTES_DIR = join(__dirname, "../routes"); |
5 | 7 |
|
6 | 8 | // Flat-route prefix for every page that renders inside an environment. The trailing dot matters: |
7 | 9 | // it excludes the layout route itself (`…env.$envParam`), which has no segment of its own. |
8 | 10 | const ENV_ROUTE_PREFIX = "_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam."; |
9 | 11 |
|
10 | 12 | /** |
11 | | - * Segments that are route files but not deeplink targets: |
12 | | - * - `_index` is the environment root, which is already where an unrecognised deeplink lands. |
| 13 | + * Segments that are route files but are not deeplink names: |
| 14 | + * - `_index` is the environment root. It is where an unrecognised deeplink already lands, and |
| 15 | + * `tasks` is the name that points at it. |
13 | 16 | * - `queues_` is Remix's "opt out of the parent layout" spelling of `queues`, not a distinct URL. |
14 | 17 | */ |
15 | | -const NOT_DEEPLINKABLE = new Set(["_index", "queues_"]); |
| 18 | +const NOT_DEEPLINK_NAMES = new Set(["_index", "queues_"]); |
| 19 | + |
| 20 | +const routeEntries = readdirSync(ROUTES_DIR); |
| 21 | + |
| 22 | +/** A route directory only contributes a route if it actually holds a `route` module. */ |
| 23 | +function isRouteModule(entry: string): boolean { |
| 24 | + const path = join(ROUTES_DIR, entry); |
| 25 | + if (!statSync(path).isDirectory()) return true; |
| 26 | + return existsSync(join(path, "route.tsx")) || existsSync(join(path, "route.ts")); |
| 27 | +} |
| 28 | + |
| 29 | +/** |
| 30 | + * The route file that a bare `/env/{env}/{target}` URL matches, or undefined when nothing does. |
| 31 | + * `target` may span segments ("waitpoints/tokens"); "" is the environment root. |
| 32 | + * |
| 33 | + * Only literal route names are considered — a param route (`metrics.$dashboardKey`) is not a page |
| 34 | + * you can land on without supplying the param, which is exactly what this needs to reject. |
| 35 | + */ |
| 36 | +function routeForTarget(target: string): string | undefined { |
| 37 | + if (target === "") { |
| 38 | + return isRouteModule(`${ENV_ROUTE_PREFIX}_index`) ? `${ENV_ROUTE_PREFIX}_index` : undefined; |
| 39 | + } |
| 40 | + |
| 41 | + const base = ENV_ROUTE_PREFIX + target.split("/").join("."); |
| 42 | + // A leaf route, or a layout whose index child supplies the bare URL. |
| 43 | + return [base, `${base}.tsx`, `${base}.ts`, `${base}._index`].find( |
| 44 | + (candidate) => routeEntries.includes(candidate) && isRouteModule(candidate) |
| 45 | + ); |
| 46 | +} |
16 | 47 |
|
17 | | -/** The first path segment of every environment page, read off the route filenames. */ |
| 48 | +/** Every first segment appearing under the environment layout. */ |
18 | 49 | function envRouteSegments(): Set<string> { |
19 | | - const entries = readdirSync(join(__dirname, "../routes")); |
20 | 50 | const segments = new Set<string>(); |
21 | | - |
22 | | - for (const entry of entries) { |
| 51 | + for (const entry of routeEntries) { |
23 | 52 | if (!entry.startsWith(ENV_ROUTE_PREFIX)) continue; |
24 | 53 | // `metrics.$dashboardKey.ts` -> `metrics`, `agents` -> `agents`, `errors._index` -> `errors` |
25 | 54 | const segment = entry.slice(ENV_ROUTE_PREFIX.length).split(/[./]/)[0]; |
26 | 55 | // Guards against a future `…env.$envParam.tsx` contributing its extension as a segment. |
27 | 56 | if (!segment || segment === "ts" || segment === "tsx") continue; |
28 | | - if (NOT_DEEPLINKABLE.has(segment)) continue; |
29 | 57 | segments.add(segment); |
30 | 58 | } |
31 | | - |
32 | 59 | return segments; |
33 | 60 | } |
34 | 61 |
|
35 | | -describe("deeplink allowlist", () => { |
36 | | - it("matches the environment layout's route segments", () => { |
37 | | - // Sorted arrays rather than sets so a mismatch names the segment that drifted. |
38 | | - expect([...ENV_PAGE_SEGMENTS].sort()).toEqual([...envRouteSegments()].sort()); |
39 | | - }); |
40 | | - |
| 62 | +describe("deeplink targets", () => { |
41 | 63 | it("found the routes directory", () => { |
42 | | - // Guards the test itself: an empty derived set would make the assertion above vacuous |
43 | | - // if the allowlist were ever emptied too. |
| 64 | + // Without this, every assertion below would pass vacuously if the glob ever broke. |
44 | 65 | expect(envRouteSegments().size).toBeGreaterThan(20); |
45 | 66 | }); |
46 | 67 |
|
47 | | - it("excludes the environment root and the layout-opt-out spelling", () => { |
48 | | - expect(ENV_PAGE_SEGMENTS.has("_index")).toBe(false); |
49 | | - expect(ENV_PAGE_SEGMENTS.has("queues_")).toBe(false); |
50 | | - // `queues` itself is still reachable — it is the real URL segment. |
51 | | - expect(ENV_PAGE_SEGMENTS.has("queues")).toBe(true); |
| 68 | + it("every target resolves to a real environment route", () => { |
| 69 | + const unresolved = [...ENV_PAGE_TARGETS.entries()] |
| 70 | + .filter(([, target]) => !routeForTarget(target)) |
| 71 | + .map(([name, target]) => `${name} -> ${target || "(environment root)"}`); |
| 72 | + |
| 73 | + expect(unresolved).toEqual([]); |
| 74 | + }); |
| 75 | + |
| 76 | + it("every environment page has a deeplink name", () => { |
| 77 | + // A segment that resolves bare is a page someone could reasonably want to link to. |
| 78 | + const missing = [...envRouteSegments()] |
| 79 | + .filter((segment) => !NOT_DEEPLINK_NAMES.has(segment)) |
| 80 | + .filter((segment) => routeForTarget(segment) && !ENV_PAGE_TARGETS.has(segment)) |
| 81 | + .sort(); |
| 82 | + |
| 83 | + expect(missing).toEqual([]); |
52 | 84 | }); |
53 | 85 |
|
54 | | - it("includes the pages that ENV_PAGE_META omits", () => { |
55 | | - // These have no entry in ENV_PAGE_META (their icon/label is special-cased when resolving |
56 | | - // page metadata), which is why the allowlist is derived from routes and not from that map. |
57 | | - for (const segment of ["tasks", "agents", "settings"]) { |
58 | | - expect(ENV_PAGE_SEGMENTS.has(segment)).toBe(true); |
| 86 | + it("names whose own segment 404s are redirected, not mapped to themselves", () => { |
| 87 | + // These exist only as the parent of param/child routes, so a bare URL matches no route. |
| 88 | + for (const segment of ["tasks", "waitpoints", "metrics"]) { |
| 89 | + expect(routeForTarget(segment)).toBeUndefined(); |
59 | 90 | } |
| 91 | + |
| 92 | + // `tasks` and `waitpoints` therefore point somewhere else; `metrics` is only a legacy redirect |
| 93 | + // shim with no page of its own, so it is deliberately not a deeplink name at all. |
| 94 | + expect(ENV_PAGE_TARGETS.get("tasks")).toBe(""); |
| 95 | + expect(ENV_PAGE_TARGETS.get("waitpoints")).toBe("waitpoints/tokens"); |
| 96 | + expect(ENV_PAGE_TARGETS.has("metrics")).toBe(false); |
| 97 | + }); |
| 98 | +}); |
| 99 | + |
| 100 | +describe("resolveDeeplinkPage", () => { |
| 101 | + it("maps a bare name to its landing page", () => { |
| 102 | + expect(resolveDeeplinkPage("apikeys")).toBe("apikeys"); |
| 103 | + expect(resolveDeeplinkPage("waitpoints")).toBe("waitpoints/tokens"); |
| 104 | + expect(resolveDeeplinkPage("tasks")).toBe(""); |
| 105 | + }); |
| 106 | + |
| 107 | + it("keeps deeper segments, which address a real sub-route", () => { |
| 108 | + expect(resolveDeeplinkPage("runs/run_123")).toBe("runs/run_123"); |
| 109 | + expect(resolveDeeplinkPage("tasks/standard/my-task")).toBe("tasks/standard/my-task"); |
| 110 | + expect(resolveDeeplinkPage("waitpoints/tokens")).toBe("waitpoints/tokens"); |
| 111 | + }); |
| 112 | + |
| 113 | + it("rejects a name that is not a page", () => { |
| 114 | + expect(resolveDeeplinkPage("")).toBeUndefined(); |
| 115 | + expect(resolveDeeplinkPage("nonsense")).toBeUndefined(); |
| 116 | + expect(resolveDeeplinkPage("metrics")).toBeUndefined(); |
| 117 | + }); |
| 118 | + |
| 119 | + it("drops traversal segments and encodes the rest", () => { |
| 120 | + expect(resolveDeeplinkPage("runs/../../../etc/passwd")).toBe("runs/etc/passwd"); |
| 121 | + expect(resolveDeeplinkPage("../runs")).toBe("runs"); |
| 122 | + expect(resolveDeeplinkPage("runs/a?b#c")).toBe("runs/a%3Fb%23c"); |
| 123 | + expect(resolveDeeplinkPage("runs//run_1")).toBe("runs/run_1"); |
60 | 124 | }); |
61 | 125 | }); |
0 commit comments