History

Add /connect/<slug> integration deep link

Resolves the slug against the tenant catalog and forwards into the integration detail page's existing add-connection flow. Unknown slugs get a not-found state. Makes the link survive sign-in: reserve connect as an org slug so cloud's callback stops reading it as an org selector, and return self-host's login to the requested path (its gate swaps the page in without ever writing a returnTo).

Rhys Sullivan committed 3 months ago

Commit 587b2cc1 parent 33f410b17 files · +659 −19Browse files

Showing the changes to e2e (1 of 17 files). Show all

Unified · Split

e2e/scenarios/connect-deep-link.test.ts+168−0
@@ -0,0 +1,168 @@
1+// The connect deep link: a customer sends an end user a direct link for ONE
2+// integration ("connect your Linear") instead of pointing them at the dashboard
3+// to hunt for it. `/connect/<integration slug>` resolves the slug against the
4+// tenant's catalog and lands the person in the SAME connect flow the
5+// integration detail page's own button opens.
6+//
7+// The slug — not a plugin id — is the stable identity here: it is what a
8+// customer can write down and template into an email.
9+//
10+// Covered:
11+// 1. Signed in, known slug → the Add connection flow is open on the right
12+// integration, with the auth methods the spec declares
13+// 2. Unknown slug → a clear not-found state, not a blank page or a
14+// crash (an end user WILL mistype these, and links outlive integrations)
15+// 3. Already connected → the deep link shows the existing connection and
16+// still offers to add another account
17+import { randomBytes, randomUUID } from "node:crypto";
18+
19+import { expect } from "@effect/vitest";
20+import { Effect } from "effect";
21+import { AccountHttpApi } from "@executor-js/api";
22+import { composePluginApi } from "@executor-js/api/server";
23+import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24+import {
25+ AuthTemplateSlug,
26+ ConnectionName,
27+ IntegrationSlug,
28+ ProviderItemId,
29+} from "@executor-js/sdk/shared";
30+
31+import { scenario } from "../src/scenario";
32+import { Api, Browser, Target } from "../src/services";
33+
34+const api = composePluginApi([openApiHttpPlugin()] as const);
35+
36+/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37+const greetSpec = (baseUrl: string): string =>
38+ JSON.stringify({
39+ openapi: "3.0.3",
40+ info: { title: "Deep Link API", version: "1.0.0" },
41+ servers: [{ url: baseUrl }],
42+ paths: {
43+ "/greet": {
44+ get: {
45+ operationId: "getGreeting",
46+ summary: "Return a greeting message",
47+ responses: { "200": { description: "A greeting" } },
48+ },
49+ },
50+ },
51+ });
52+
53+scenario(
54+ "Connect · /connect/<slug> lands an end user in that integration's connect flow",
55+ { timeout: 180_000 },
56+ Effect.gen(function* () {
57+ const target = yield* Target;
58+ const browser = yield* Browser;
59+ const { client } = yield* Api;
60+
61+ const identity = yield* target.newIdentity();
62+ const apiClient = yield* client(api, identity);
63+
64+ // Selfhost shares one bootstrap-admin identity, so a unique slug keeps
65+ // repeated and parallel runs out of each other's catalogs.
66+ const slug = `connect-dl-${randomBytes(4).toString("hex")}`;
67+ const specBaseUrl = "http://127.0.0.1:59999"; // never contacted here
68+
69+ // Org-scoped hosts canonicalize console URLs onto the active org's slug, so
70+ // the deep link may legitimately arrive at either `/connect/x` or
71+ // `/<org>/connect/x`. Read the slug the shell will canonicalize onto.
72+ const accountClient = yield* client(AccountHttpApi, identity);
73+ const me = yield* accountClient.account.me();
74+ const orgSlug = me.organization?.slug ?? null;
75+ const detailPath = orgSlug ? `/${orgSlug}/integrations/${slug}` : `/integrations/${slug}`;
76+
77+ yield* Effect.ensuring(
78+ Effect.gen(function* () {
79+ const added = yield* apiClient.openapi.addSpec({
80+ payload: {
81+ spec: { kind: "blob", value: greetSpec(specBaseUrl) },
82+ slug,
83+ name: "Deep Link API",
84+ baseUrl: specBaseUrl,
85+ authenticationTemplate: [
86+ {
87+ slug: "apiKey",
88+ type: "apiKey",
89+ headers: { "x-api-key": [{ type: "variable", name: "token" }] },
90+ },
91+ ],
92+ },
93+ });
94+ expect(added.slug, "the integration keeps the requested slug").toBe(slug);
95+
96+ yield* browser.session(identity, async ({ page, step }) => {
97+ await step("Open the connect deep link for the seeded integration", async () => {
98+ await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
99+ // It forwards into the integration's OWN detail route rather than
100+ // rendering a parallel connect UI, carrying the add-account handoff.
101+ await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
102+ expect(new URL(page.url()).searchParams.get("addAccount")).toBe("1");
103+ });
104+
105+ await step("The Add connection flow is open for that integration", async () => {
106+ await page
107+ .getByRole("heading", { name: /Add connection/ })
108+ .waitFor({ timeout: 20_000 });
109+ // The right integration, and the declared auth method is offered —
110+ // proof it resolved the slug rather than opening a generic dialog.
111+ await page
112+ .getByRole("dialog")
113+ .getByRole("textbox", { name: "x-api-key" })
114+ .waitFor({ timeout: 20_000 });
115+ });
116+
117+ await step("An unknown slug is a clear not-found, not a blank page", async () => {
118+ await page.goto("/connect/zz-no-such-integration", { waitUntil: "networkidle" });
119+ await page
120+ .getByText("Unknown integration: zz-no-such-integration")
121+ .waitFor({ timeout: 30_000 });
122+ await page.getByRole("link", { name: "Back to integrations" }).waitFor();
123+ });
124+ });
125+
126+ // Already connected: the deep link must show the existing connection
127+ // and still let the person add another account.
128+ const providers = yield* apiClient.providers.list();
129+ expect(providers.length, "a credential provider is available").toBeGreaterThan(0);
130+ yield* apiClient.connections.create({
131+ payload: {
132+ owner: "org",
133+ name: ConnectionName.make("existing"),
134+ integration: IntegrationSlug.make(slug),
135+ template: AuthTemplateSlug.make("apiKey"),
136+ from: { provider: providers[0]!, id: ProviderItemId.make(randomUUID()) },
137+ },
138+ });
139+
140+ yield* browser.session(identity, async ({ page, step }) => {
141+ await step("The deep link surfaces the existing connection", async () => {
142+ await page.goto(`/connect/${slug}`, { waitUntil: "networkidle" });
143+ await page.waitForURL((url) => url.pathname === detailPath, { timeout: 30_000 });
144+ // The connect flow still opens (this link means "connect me"), and
145+ // the already-saved account is visible behind it.
146+ await page
147+ .getByRole("heading", { name: /Add connection/ })
148+ .waitFor({ timeout: 20_000 });
149+ await page.keyboard.press("Escape");
150+ await page.getByText("existing").first().waitFor({ timeout: 20_000 });
151+ });
152+ });
153+ }),
154+ Effect.gen(function* () {
155+ yield* apiClient.connections
156+ .remove({
157+ params: {
158+ owner: "org",
159+ integration: IntegrationSlug.make(slug),
160+ name: ConnectionName.make("existing"),
161+ },
162+ })
163+ .pipe(Effect.ignore);
164+ yield* apiClient.openapi.removeSpec({ params: { slug } }).pipe(Effect.ignore);
165+ }).pipe(Effect.ignore),
166+ );
167+ }),
168+);