Unified · Split
| @@ -8,12 +8,10 @@ import { randomBytes } from "node:crypto"; · 7 unmodified lines | ||
| 8 | 8 | |
| 9 | 9 | import { expect } from "@effect/vitest"; |
| 10 | 10 | import { Effect } from "effect"; |
| 11 | +import type { Page } from "playwright"; | |
| 11 | 12 | import { composePluginApi } from "@executor-js/api/server"; |
| 12 | 13 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 13 | 14 | import { |
| 14 | − AuthTemplateSlug, | |
| 15 | − ConnectionName, | |
| 16 | − EXECUTOR_ORG_SELECTOR_HEADER, | |
| 17 | 15 | IntegrationSlug, |
| 18 | 16 | OAUTH_CALLBACK_ORG_QUERY_PARAM, |
| 19 | 17 | OAuthClientSlug, |
| @@ -45,6 +43,68 @@ const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; · 25 unmodified lines | ||
| 45 | 43 | |
| 46 | 44 | const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); |
| 47 | 45 | |
| 46 | +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 47 | + | |
| 48 | +const setWorkosSessionCookie = async (page: Page, baseUrl: string, cookie: string) => { | |
| 49 | + await page.context().addCookies([ | |
| 50 | + { | |
| 51 | + name: "wos-session", | |
| 52 | + value: cookieValue(cookie), | |
| 53 | + url: baseUrl, | |
| 54 | + }, | |
| 55 | + ]); | |
| 56 | +}; | |
| 57 | + | |
| 58 | +const expectOrgShell = async ( | |
| 59 | + page: Page, | |
| 60 | + org: { readonly name: string; readonly slug: string }, | |
| 61 | +) => { | |
| 62 | + await page.waitForURL( | |
| 63 | + (url) => url.pathname === `/${org.slug}` || url.pathname === `/${org.slug}/`, | |
| 64 | + { | |
| 65 | + timeout: 30_000, | |
| 66 | + }, | |
| 67 | + ); | |
| 68 | + await page.getByRole("button", { name: new RegExp(escapeRegExp(org.name)) }).waitFor(); | |
| 69 | + await page.getByRole("heading", { name: "Integrations" }).waitFor(); | |
| 70 | +}; | |
| 71 | + | |
| 72 | +const installSameWindowOAuthPopup = async (page: Page) => { | |
| 73 | + await page.addInitScript(() => { | |
| 74 | + window.open = () => | |
| 75 | + ({ | |
| 76 | + get closed() { | |
| 77 | + return false; | |
| 78 | + }, | |
| 79 | + close() {}, | |
| 80 | + focus() {}, | |
| 81 | + location: { | |
| 82 | + get href() { | |
| 83 | + return window.location.href; | |
| 84 | + }, | |
| 85 | + set href(value: string) { | |
| 86 | + window.location.assign(String(value)); | |
| 87 | + }, | |
| 88 | + }, | |
| 89 | + }) as Window; | |
| 90 | + }); | |
| 91 | +}; | |
| 92 | + | |
| 93 | +const submitProviderLoginFromPage = async (page: Page): Promise<string> => | |
| 94 | + fetch(page.url(), { | |
| 95 | + method: "POST", | |
| 96 | + redirect: "manual", | |
| 97 | + headers: { | |
| 98 | + authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | |
| 99 | + }, | |
| 100 | + }).then((response) => { | |
| 101 | + const location = response.headers.get("location"); | |
| 102 | + if (response.status !== 302 || !location) { | |
| 103 | + throw new Error(`provider did not return callback location (${response.status})`); | |
| 104 | + } | |
| 105 | + return new URL(location, page.url()).toString(); | |
| 106 | + }); | |
| 107 | + | |
| 48 | 108 | const activeOrg = (baseUrl: string, cookie: string) => |
| 49 | 109 | Effect.promise(async () => { |
| 50 | 110 | const response = await fetch(new URL("/api/auth/me", baseUrl), { |
| @@ -130,16 +190,7 @@ scenario( · 79 unmodified lines | ||
| 130 | 190 | ); |
| 131 | 191 | expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug); |
| 132 | 192 | |
| 133 | − const scopedToOrgAWithCookieB: Identity = { | |
| 134 | − ...identity, | |
| 135 | − headers: { | |
| 136 | − cookie: sessionB, | |
| 137 | − [EXECUTOR_ORG_SELECTOR_HEADER]: orgA.slug, | |
| 138 | − }, | |
| 139 | − cookies: [{ name: "wos-session", value: cookieValue(sessionB) }], | |
| 140 | − }; | |
| 141 | − | |
| 142 | − const client = yield* makeApiClient(api, scopedToOrgAWithCookieB); | |
| 193 | + const client = yield* makeApiClient(api, identity); | |
| 143 | 194 | |
| 144 | 195 | const integration = IntegrationSlug.make(unique("oauthscope")); |
| 145 | 196 | yield* client.openapi.addSpec({ |
| @@ -159,42 +210,42 @@ scenario( · 13 unmodified lines | ||
| 159 | 210 | }, |
| 160 | 211 | }); |
| 161 | 212 | |
| 162 | − const started = yield* client.oauth.start({ | |
| 163 | − payload: { | |
| 164 | − client: clientSlug, | |
| 165 | − clientOwner: "org", | |
| 166 | − owner: "org", | |
| 167 | − name: ConnectionName.make("main"), | |
| 168 | − integration, | |
| 169 | − template: AuthTemplateSlug.make("oauth"), | |
| 170 | − }, | |
| 171 | − }); | |
| 172 | − expect(started.status, "oauth.start begins at the provider").toBe("redirect"); | |
| 173 | − const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; | |
| 174 | − | |
| 175 | − const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" })); | |
| 176 | − expect(authorize.status, "the provider asks the user to log in").toBe(302); | |
| 177 | − const consent = yield* Effect.promise(() => | |
| 178 | − fetch(authorize.headers.get("location") ?? "", { | |
| 179 | − method: "POST", | |
| 180 | − redirect: "manual", | |
| 181 | − headers: { | |
| 182 | − authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | |
| 183 | − }, | |
| 184 | − }), | |
| 185 | − ); | |
| 186 | − expect(consent.status, "provider consent redirects back to Executor").toBe(302); | |
| 187 | − const callback = new URL(consent.headers.get("location") ?? ""); | |
| 188 | − const callbackPath = `${callback.pathname}${callback.search}`; | |
| 189 | − | |
| 190 | − yield* browser.session(scopedToOrgAWithCookieB, async ({ page, step }) => { | |
| 191 | − await step( | |
| 192 | − "Provider returns to the OAuth callback while the cookie is pinned to org B", | |
| 193 | − async () => { | |
| 194 | − const response = await page.goto(callbackPath, { waitUntil: "networkidle" }); | |
| 195 | − expect(response?.status(), "the callback renders its popup result page").toBe(200); | |
| 196 | − }, | |
| 197 | − ); | |
| 213 | + let callback = new URL("http://invalid.example"); | |
| 214 | + yield* browser.session(identity, async ({ page, step }) => { | |
| 215 | + await installSameWindowOAuthPopup(page); | |
| 216 | + | |
| 217 | + await step("Land in the original organization", async () => { | |
| 218 | + await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); | |
| 219 | + await expectOrgShell(page, orgA); | |
| 220 | + }); | |
| 221 | + | |
| 222 | + await step("The browser session is switched to another organization", async () => { | |
| 223 | + await setWorkosSessionCookie(page, target.baseUrl, sessionB); | |
| 224 | + await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); | |
| 225 | + await expectOrgShell(page, orgB); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + await step("Start OAuth from the original organization's add-connection flow", async () => { | |
| 229 | + await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, { | |
| 230 | + waitUntil: "networkidle", | |
| 231 | + }); | |
| 232 | + await page.getByRole("heading", { name: /Add connection/ }).waitFor({ | |
| 233 | + timeout: 30_000, | |
| 234 | + }); | |
| 235 | + await page.getByRole("button", { name: "Connect with OAuth" }).click(); | |
| 236 | + await page.waitForURL( | |
| 237 | + (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login", | |
| 238 | + { timeout: 30_000 }, | |
| 239 | + ); | |
| 240 | + await page.getByText("OAuth test login").waitFor(); | |
| 241 | + }); | |
| 242 | + | |
| 243 | + await step("The provider returns to the OAuth callback", async () => { | |
| 244 | + const callbackUrl = await submitProviderLoginFromPage(page); | |
| 245 | + callback = new URL(callbackUrl); | |
| 246 | + const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); | |
| 247 | + expect(response?.status(), "the callback renders its popup result page").toBe(200); | |
| 248 | + }); | |
| 198 | 249 | |
| 199 | 250 | const body = (await page.locator("body").textContent())?.trim() ?? ""; |
| 200 | 251 | expect( |