Unified · Split
| @@ -8,12 +8,10 @@ import { randomBytes } from "node:crypto"; · 7 unmodified lines | |||
| 8 | 8 | ||
| 9 | import { expect } from "@effect/vitest"; | 9 | import { expect } from "@effect/vitest"; |
| 10 | import { Effect } from "effect"; | 10 | import { Effect } from "effect"; |
| 11 | import type { Page } from "playwright"; | ||
| 11 | import { composePluginApi } from "@executor-js/api/server"; | 12 | import { composePluginApi } from "@executor-js/api/server"; |
| 12 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | 13 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
| 13 | import { | 14 | import { |
| 14 | AuthTemplateSlug, | ||
| 15 | ConnectionName, | ||
| 16 | EXECUTOR_ORG_SELECTOR_HEADER, | ||
| 17 | IntegrationSlug, | 15 | IntegrationSlug, |
| 18 | OAUTH_CALLBACK_ORG_QUERY_PARAM, | 16 | OAUTH_CALLBACK_ORG_QUERY_PARAM, |
| 19 | OAuthClientSlug, | 17 | OAuthClientSlug, |
| @@ -45,6 +43,68 @@ const cookieOf = (identity: Identity): string => identity.headers?.cookie ?? ""; · 25 unmodified lines | |||
| 45 | 43 | ||
| 46 | const originHeaders = (baseUrl: string) => ({ origin: new URL(baseUrl).origin }); | 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 | const activeOrg = (baseUrl: string, cookie: string) => | 108 | const activeOrg = (baseUrl: string, cookie: string) => |
| 49 | Effect.promise(async () => { | 109 | Effect.promise(async () => { |
| 50 | const response = await fetch(new URL("/api/auth/me", baseUrl), { | 110 | const response = await fetch(new URL("/api/auth/me", baseUrl), { |
| @@ -130,16 +190,7 @@ scenario( · 79 unmodified lines | |||
| 130 | ); | 190 | ); |
| 131 | expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug); | 191 | expect(orgB.slug, "the test has two distinct org URLs").not.toBe(orgA.slug); |
| 132 | 192 | ||
| 133 | const scopedToOrgAWithCookieB: Identity = { | 193 | const client = yield* makeApiClient(api, 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); | ||
| 143 | 194 | ||
| 144 | const integration = IntegrationSlug.make(unique("oauthscope")); | 195 | const integration = IntegrationSlug.make(unique("oauthscope")); |
| 145 | yield* client.openapi.addSpec({ | 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({ | 213 | let callback = new URL("http://invalid.example"); |
| 163 | payload: { | 214 | yield* browser.session(identity, async ({ page, step }) => { |
| 164 | client: clientSlug, | 215 | await installSameWindowOAuthPopup(page); |
| 165 | clientOwner: "org", | 216 | |
| 166 | owner: "org", | 217 | await step("Land in the original organization", async () => { |
| 167 | name: ConnectionName.make("main"), | 218 | await page.goto(`/${orgA.slug}`, { waitUntil: "networkidle" }); |
| 168 | integration, | 219 | await expectOrgShell(page, orgA); |
| 169 | template: AuthTemplateSlug.make("oauth"), | 220 | }); |
| 170 | }, | 221 | |
| 171 | }); | 222 | await step("The browser session is switched to another organization", async () => { |
| 172 | expect(started.status, "oauth.start begins at the provider").toBe("redirect"); | 223 | await setWorkosSessionCookie(page, target.baseUrl, sessionB); |
| 173 | const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; | 224 | await page.goto(`/${orgB.slug}`, { waitUntil: "networkidle" }); |
| 174 | 225 | await expectOrgShell(page, orgB); | |
| 175 | const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" })); | 226 | }); |
| 176 | expect(authorize.status, "the provider asks the user to log in").toBe(302); | 227 | |
| 177 | const consent = yield* Effect.promise(() => | 228 | await step("Start OAuth from the original organization's add-connection flow", async () => { |
| 178 | fetch(authorize.headers.get("location") ?? "", { | 229 | await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, { |
| 179 | method: "POST", | 230 | waitUntil: "networkidle", |
| 180 | redirect: "manual", | 231 | }); |
| 181 | headers: { | 232 | await page.getByRole("heading", { name: /Add connection/ }).waitFor({ |
| 182 | authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | 233 | timeout: 30_000, |
| 183 | }, | 234 | }); |
| 184 | }), | 235 | await page.getByRole("button", { name: "Connect with OAuth" }).click(); |
| 185 | ); | 236 | await page.waitForURL( |
| 186 | expect(consent.status, "provider consent redirects back to Executor").toBe(302); | 237 | (url) => url.origin === new URL(oauth.issuerUrl).origin && url.pathname === "/login", |
| 187 | const callback = new URL(consent.headers.get("location") ?? ""); | 238 | { timeout: 30_000 }, |
| 188 | const callbackPath = `${callback.pathname}${callback.search}`; | 239 | ); |
| 189 | 240 | await page.getByText("OAuth test login").waitFor(); | |
| 190 | yield* browser.session(scopedToOrgAWithCookieB, async ({ page, step }) => { | 241 | }); |
| 191 | await step( | 242 | |
| 192 | "Provider returns to the OAuth callback while the cookie is pinned to org B", | 243 | await step("The provider returns to the OAuth callback", async () => { |
| 193 | async () => { | 244 | const callbackUrl = await submitProviderLoginFromPage(page); |
| 194 | const response = await page.goto(callbackPath, { waitUntil: "networkidle" }); | 245 | callback = new URL(callbackUrl); |
| 195 | expect(response?.status(), "the callback renders its popup result page").toBe(200); | 246 | const response = await page.goto(callbackUrl, { waitUntil: "networkidle" }); |
| 196 | }, | 247 | expect(response?.status(), "the callback renders its popup result page").toBe(200); |
| 197 | ); | 248 | }); |
| 198 | 249 | ||
| 199 | const body = (await page.locator("body").textContent())?.trim() ?? ""; | 250 | const body = (await page.locator("body").textContent())?.trim() ?? ""; |
| 200 | expect( | 251 | expect( |