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
17import { randomBytes, randomUUID } from "node:crypto";
18
19import { expect } from "@effect/vitest";
20import { Effect } from "effect";
21import { AccountHttpApi } from "@executor-js/api";
22import { composePluginApi } from "@executor-js/api/server";
23import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api";
24import {
25 AuthTemplateSlug,
26 ConnectionName,
27 IntegrationSlug,
28 ProviderItemId,
29} from "@executor-js/sdk/shared";
30
31import { scenario } from "../src/scenario";
32import { Api, Browser, Target } from "../src/services";
33
34const api = composePluginApi([openApiHttpPlugin()] as const);
35
36/** OpenAPI 3 spec with a single operation — enough to be a real catalog entry. */
37const 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
53scenario(
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);