|
| 1 | +import { matchRoutes, resolveTo } from "@remix-run/router"; |
| 2 | +import { readFileSync } from "node:fs"; |
| 3 | +import { join } from "node:path"; |
| 4 | +import { describe, expect, it } from "vitest"; |
| 5 | +import { impersonationConsentPostBackPath, impersonationDestinationPath } from "./pathBuilder"; |
| 6 | + |
| 7 | +// The route that renders the impersonation consent page, as the flat-route |
| 8 | +// convention compiles `_app.@.orgs.$organizationSlug.$.tsx`. |
| 9 | +const CONSENT_ROUTES = [ |
| 10 | + { |
| 11 | + id: "routes/_app", |
| 12 | + children: [ |
| 13 | + { |
| 14 | + id: "routes/_app.@.orgs.$organizationSlug.$", |
| 15 | + path: "@/orgs/:organizationSlug/*", |
| 16 | + }, |
| 17 | + ], |
| 18 | + }, |
| 19 | +]; |
| 20 | + |
| 21 | +/** |
| 22 | + * What the consent route's `action` would receive for a POST to `pathname`: |
| 23 | + * the organization slug and the splat, straight off the router. |
| 24 | + */ |
| 25 | +function actionParamsFor(pathname: string) { |
| 26 | + const matches = matchRoutes(CONSENT_ROUTES, pathname); |
| 27 | + const leaf = matches?.[matches.length - 1]; |
| 28 | + return { |
| 29 | + organizationSlug: leaf?.params.organizationSlug, |
| 30 | + splat: leaf?.params["*"], |
| 31 | + }; |
| 32 | +} |
| 33 | + |
| 34 | +/** |
| 35 | + * What `<Form method="post">` with NO `action` prop resolves to, reproducing |
| 36 | + * `useFormAction()`: `resolveTo(".", …)` over the matched routes' contributing |
| 37 | + * pathnames. This app does not enable `future.v3_relativeSplatPath`, so each |
| 38 | + * contributing match supplies its `pathnameBase`. |
| 39 | + */ |
| 40 | +function relativeFormAction(pathname: string) { |
| 41 | + const matches = matchRoutes(CONSENT_ROUTES, pathname) ?? []; |
| 42 | + const contributing = matches.filter((m, i) => i === 0 || Boolean(m.route.path)); |
| 43 | + const routePathnames = contributing.map((m) => m.pathnameBase); |
| 44 | + return resolveTo(".", routePathnames, pathname, false).pathname; |
| 45 | +} |
| 46 | + |
| 47 | +describe("impersonation consent post-back path", () => { |
| 48 | + const slug = "acme-inc"; |
| 49 | + // A `/@/runs/<id>` link redirects here: a deep run path plus the `?span=` |
| 50 | + // that selects which span to open. |
| 51 | + const splat = "projects/proj_123/env/prod/runs/run_abc"; |
| 52 | + const search = "?span=span_xyz"; |
| 53 | + |
| 54 | + it("keeps the splat and the query string", () => { |
| 55 | + expect(impersonationConsentPostBackPath(slug, splat, search)).toBe( |
| 56 | + `/@/orgs/${slug}/${splat}${search}` |
| 57 | + ); |
| 58 | + expect(impersonationDestinationPath(slug, splat, search)).toBe( |
| 59 | + `/orgs/${slug}/${splat}${search}` |
| 60 | + ); |
| 61 | + }); |
| 62 | + |
| 63 | + it("omits the query string when there isn't one", () => { |
| 64 | + expect(impersonationConsentPostBackPath(slug, splat)).toBe(`/@/orgs/${slug}/${splat}`); |
| 65 | + expect(impersonationDestinationPath(slug, splat)).toBe(`/orgs/${slug}/${splat}`); |
| 66 | + }); |
| 67 | + |
| 68 | + it("round-trips the slug and splat back to the action", () => { |
| 69 | + const postBackPath = impersonationConsentPostBackPath(slug, splat, search); |
| 70 | + |
| 71 | + expect(actionParamsFor(new URL(postBackPath, "http://localhost").pathname)).toEqual({ |
| 72 | + organizationSlug: slug, |
| 73 | + splat, |
| 74 | + }); |
| 75 | + }); |
| 76 | + |
| 77 | + it("the destination the action redirects to matches the one the page displayed", () => { |
| 78 | + const postBackPath = impersonationConsentPostBackPath(slug, splat, search); |
| 79 | + const url = new URL(postBackPath, "http://localhost"); |
| 80 | + const params = actionParamsFor(url.pathname); |
| 81 | + |
| 82 | + // What `startImpersonation` builds from the action's params + request URL. |
| 83 | + expect(impersonationDestinationPath(params.organizationSlug!, params.splat!, url.search)).toBe( |
| 84 | + impersonationDestinationPath(slug, splat, search) |
| 85 | + ); |
| 86 | + }); |
| 87 | + |
| 88 | + // The regression this file exists for. A relative `<Form>` action drops the |
| 89 | + // splat, so the action would start impersonation and land the admin on the |
| 90 | + // organization root rather than the deep link the consent page promised. |
| 91 | + it("a relative form action would drop the splat, so the path must be explicit", () => { |
| 92 | + const consentUrl = `/@/orgs/${slug}/${splat}`; |
| 93 | + |
| 94 | + expect(relativeFormAction(consentUrl)).toBe(`/@/orgs/${slug}`); |
| 95 | + expect(actionParamsFor(relativeFormAction(consentUrl))).toEqual({ |
| 96 | + organizationSlug: slug, |
| 97 | + splat: "", |
| 98 | + }); |
| 99 | + |
| 100 | + // The explicit path does not. |
| 101 | + expect(impersonationConsentPostBackPath(slug, splat)).toBe(consentUrl); |
| 102 | + }); |
| 103 | + |
| 104 | + // Ideally this would render the route and read the emitted `action` |
| 105 | + // attribute, but the route module imports `~/env.server`, which validates the |
| 106 | + // full server environment at import time and so cannot be pulled into a unit |
| 107 | + // test. Asserting on the source is the next best guard: it fails if the |
| 108 | + // `action` prop is ever dropped, which is the whole bug. |
| 109 | + it("the consent form names its action explicitly", () => { |
| 110 | + const source = readFileSync( |
| 111 | + join(__dirname, "../routes/_app.@.orgs.$organizationSlug.$.tsx"), |
| 112 | + "utf8" |
| 113 | + ); |
| 114 | + |
| 115 | + const form = source.match(/<Form\b[^>]*>/); |
| 116 | + expect(form).not.toBeNull(); |
| 117 | + expect(form![0]).toMatch(/action=\{postBackPath\}/); |
| 118 | + }); |
| 119 | +}); |
0 commit comments