-
Notifications
You must be signed in to change notification settings - Fork 154
Repair the self-host e2e suite and run it in CI #1239
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| import { randomBytes } from "node:crypto"; | ||
|
|
||
| import { expect } from "@effect/vitest"; | ||
| import { Effect } from "effect"; | ||
| import { composePluginApi } from "@executor-js/api/server"; | ||
| import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; | ||
| import { | ||
| AuthTemplateSlug, | ||
| ConnectionName, | ||
| decodeOAuthCallbackState, | ||
| IntegrationSlug, | ||
| OAuthClientSlug, | ||
| } from "@executor-js/sdk/shared"; | ||
| import { serveOAuthTestServer } from "@executor-js/sdk/testing"; | ||
|
|
||
| import { scenario } from "../src/scenario"; | ||
| import { Api, Target } from "../src/services"; | ||
|
|
||
| const api = composePluginApi([openApiHttpPlugin()] as const); | ||
|
|
||
| const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; | ||
|
|
||
| const oauthIntegrationSpec = (oauth: { | ||
| readonly authorizationEndpoint: string; | ||
| readonly tokenEndpoint: string; | ||
| }) => | ||
| ({ | ||
| spec: { | ||
| kind: "blob" as const, | ||
| value: JSON.stringify({ | ||
| openapi: "3.0.3", | ||
| info: { title: "OAuth-protected API", version: "1.0.0" }, | ||
| paths: { | ||
| "/me": { | ||
| get: { | ||
| operationId: "getMe", | ||
| tags: ["default"], | ||
| responses: { "200": { description: "the caller" } }, | ||
| }, | ||
| }, | ||
| }, | ||
| }), | ||
| }, | ||
| baseUrl: "http://127.0.0.1:59999", | ||
| authenticationTemplate: [ | ||
| { | ||
| slug: "oauth", | ||
| kind: "oauth2" as const, | ||
| authorizationUrl: oauth.authorizationEndpoint, | ||
| tokenUrl: oauth.tokenEndpoint, | ||
| scopes: ["read"], | ||
| }, | ||
| ], | ||
| }) as const; | ||
|
|
||
| // Better Auth email sign-in → session cookie, so the callback (a browser GET | ||
| // behind the session) can be driven with a plain authenticated fetch. Mirrors | ||
| // what the API surface does internally; kept local to keep this a black-box HTTP | ||
| // journey with no browser dependency. | ||
| const sessionCookie = (baseUrl: string, credentials: { email: string; password: string }) => | ||
| Effect.promise(async () => { | ||
| const response = await fetch(new URL("/api/auth/sign-in/email", baseUrl), { | ||
| method: "POST", | ||
| headers: { "content-type": "application/json", origin: new URL(baseUrl).origin }, | ||
| body: JSON.stringify(credentials), | ||
| }); | ||
| const cookie = (response.headers.getSetCookie?.() ?? []).map((c) => c.split(";")[0]).join("; "); | ||
| if (!cookie) throw new Error(`sign-in set no cookie (${response.status})`); | ||
| return cookie; | ||
| }); | ||
|
|
||
| // Regression guard for the org-wrapped callback state. Self-host binds every | ||
| // request to an org slug ("default"), so `oauth.start` wraps the raw session | ||
| // token in the state it sends the provider. The provider echoes that wrapped | ||
| // value back on the callback; the shared popup callback must unwrap it to the | ||
| // raw token before looking up the session. Before the fix it passed the wrapped | ||
| // value straight to `oauth.complete`, which looks up by the raw token and failed | ||
| // with "OAuth session expired or not found". | ||
| scenario( | ||
| "OAuth callback · a self-host org-context popup callback completes with the wrapped state", | ||
| {}, | ||
| Effect.gen(function* () { | ||
| const target = yield* Target; | ||
| const { client: makeApiClient } = yield* Api; | ||
| const oauth = yield* serveOAuthTestServer(); | ||
| const identity = yield* target.newIdentity(); | ||
| const client = yield* makeApiClient(api, identity); | ||
|
|
||
| const integration = IntegrationSlug.make(unique("selfhostorgstate")); | ||
| yield* client.openapi.addSpec({ | ||
| payload: { ...oauthIntegrationSpec(oauth), slug: integration }, | ||
| }); | ||
|
|
||
| const clientSlug = OAuthClientSlug.make(unique("selfhostorgstate")); | ||
| yield* client.oauth.createClient({ | ||
| payload: { | ||
| owner: "org", | ||
| slug: clientSlug, | ||
| authorizationUrl: oauth.authorizationEndpoint, | ||
| tokenUrl: oauth.tokenEndpoint, | ||
| grant: "authorization_code", | ||
| clientId: "test-client", | ||
| clientSecret: "test-secret", | ||
| }, | ||
| }); | ||
|
|
||
| const started = yield* client.oauth.start({ | ||
| payload: { | ||
| client: clientSlug, | ||
| clientOwner: "org", | ||
| owner: "org", | ||
| name: ConnectionName.make("main"), | ||
| integration, | ||
| template: AuthTemplateSlug.make("oauth"), | ||
| }, | ||
| }); | ||
| expect(started.status, "oauth.start begins at the provider").toBe("redirect"); | ||
| const authorizationUrl = started.status === "redirect" ? started.authorizationUrl : ""; | ||
|
|
||
| // The bug's precondition: the state sent to the provider is NOT the raw | ||
| // session token, it is the org-slug-wrapped envelope. If this stops being | ||
| // true the callback path below no longer exercises the regression. | ||
| const providerState = new URL(authorizationUrl).searchParams.get("state") ?? ""; | ||
| expect( | ||
| decodeOAuthCallbackState(providerState), | ||
| "self-host org context wraps the OAuth state with the org slug before redirecting", | ||
| ).not.toBeNull(); | ||
|
|
||
| const authorize = yield* Effect.promise(() => fetch(authorizationUrl, { redirect: "manual" })); | ||
| expect(authorize.status, "the provider asks the user to log in").toBe(302); | ||
| const consent = yield* Effect.promise(() => | ||
| fetch(authorize.headers.get("location") ?? "", { | ||
| method: "POST", | ||
| redirect: "manual", | ||
| headers: { | ||
| authorization: `Basic ${Buffer.from("alice:password").toString("base64")}`, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Basic auth header for the OAuth consent step hardcodes |
||
| }, | ||
| }), | ||
| ); | ||
| expect(consent.status, "provider consent redirects back to Executor").toBe(302); | ||
| const callback = new URL(consent.headers.get("location") ?? ""); | ||
| const callbackPath = `${callback.pathname}${callback.search}`; | ||
| expect( | ||
| callback.searchParams.get("state"), | ||
| "the provider echoes the wrapped state back on the callback", | ||
| ).toBe(providerState); | ||
|
|
||
| const cookie = yield* sessionCookie(target.baseUrl, identity.credentials!); | ||
| const response = yield* Effect.promise(() => | ||
| fetch(new URL(callbackPath, target.baseUrl), { headers: { cookie } }), | ||
| ); | ||
| expect(response.status, "the callback renders its popup HTML").toBe(200); | ||
| const html = yield* Effect.promise(() => response.text()); | ||
|
|
||
| expect( | ||
| html, | ||
| "the wrapped state is unwrapped to the raw token, so the session is found and completes", | ||
| ).toContain("Connected"); | ||
| expect( | ||
| html, | ||
| "the raw session token is recovered from the wrapped state (no expired-session error)", | ||
| ).not.toContain("OAuth session expired or not found"); | ||
| }).pipe(Effect.scoped), | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
timeout-minuteson the new jobThe
e2e-selfhostjob boots a dev server via globalsetup before running the suite. If the server fails to start or hangs, the job will block until GitHub Actions' 6-hour default, holding up the queue and burning CI minutes. The existinge2e-localjob has the same omission, but adding atimeout-minutes(e.g.30) here would bound the blast radius for a new job that is now running on every PR.