// Selfhost (browser): a registered OAuth app is managed entirely from the// Add-connection modal — there is no separate "OAuth apps" page. The user// registers a bring-your-own app for an integration, edits its stored client// id, and removes it, all from the OAuth app picker inside the modal.//// The integration only needs to DECLARE an OAuth method for the picker to show;// registering/editing/removing an app touches stored credentials only and never// calls the authorization/token endpoints, so a static issuer is enough and no// OAuth provider is started.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 { scenario } from "../src/scenario";import { Api, Browser, Target } from "../src/services";import { visit } from "../src/surfaces/browser"; const api = composePluginApi([openApiHttpPlugin()] as const); /** Minimal OpenAPI 3 spec — one operation, server never contacted. */const greetSpec = (baseUrl: string): string => JSON.stringify({ openapi: "3.0.3", info: { title: "OAuth Modal API", version: "1.0.0" }, servers: [{ url: baseUrl }], paths: { "/greet": { get: { operationId: "getGreeting", summary: "Return a greeting", responses: { "200": { description: "A greeting" } }, }, }, }, }); scenario( "OAuth apps · a registered app is edited and removed from the connect modal", { timeout: 180_000 }, Effect.gen(function* () { const target = yield* Target; const { client: makeApiClient } = yield* Api; const browser = yield* Browser; const identity = yield* target.newIdentity(); const client = yield* makeApiClient(api, identity); // Selfhost shares the bootstrap-admin identity, so prefix every resource // with a per-run id to stay out of parallel/repeated runs' way. const id = randomBytes(4).toString("hex"); const integration = `oauth-modal-scn-${id}`; const appName = `oauthmodalapp${id}`; // lowercase+digits → slug === appName // The picker humanizes the slug for display ("oauthmodalappab12" → // "Oauthmodalappab12"); used to assert the row is gone after removal. const appDisplayName = appName.charAt(0).toUpperCase() + appName.slice(1); const specBaseUrl = "http://127.0.0.1:59998"; // never contacted // Stand up an integration that declares a bring-your-own OAuth2 method. // The endpoints are inert — app management never calls them. yield* client.openapi.addSpec({ payload: { spec: { kind: "blob", value: greetSpec(specBaseUrl) }, slug: integration, baseUrl: specBaseUrl, authenticationTemplate: [ { slug: "oauth", kind: "oauth2", authorizationUrl: "https://auth.example/authorize", tokenUrl: "https://auth.example/token", scopes: [], }, ], }, }); yield* Effect.ensuring( Effect.gen(function* () { yield* browser.session(identity, async ({ page, step }) => { const actions = page.getByRole("button", { name: `Actions for ${appName}` }); await step("Open the integration and start a new connection", async () => { await visit(page, `/integrations/${integration}`); await page.getByRole("button", { name: "Add connection" }).click(); // OAuth2 is the integration's only method, so the modal opens on // the OAuth app step with nothing registered yet. (`exact` avoids // the "Register app for help" tooltip button in the form.) await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); }); await step("Register a bring-your-own OAuth app", async () => { await page.getByRole("button", { name: "Register app", exact: true }).click(); await page.locator("#oauth-app-name").fill(appName); await page.locator("#oauth-client-id").fill("client-one"); await page.locator("#oauth-client-secret").fill("secret-one"); await page.getByRole("button", { name: "Register app", exact: true }).click(); // Back on the picker, the new app is selectable AND manageable — // the per-app actions menu is what replaced the old apps page. await actions.waitFor(); }); await step("Edit opens the app prefilled with its stored client id", async () => { await actions.click(); await page.getByRole("menuitem", { name: "Edit" }).click(); await page.getByText(`Edit ${appName}`).waitFor(); expect( await page.locator("#oauth-client-id").inputValue(), "the edit form prefills the stored client id", ).toBe("client-one"); }); await step("Change the client id and save the edit", async () => { await page.locator("#oauth-client-id").fill("client-two"); await page.locator("#oauth-client-secret").fill("secret-two"); await page.getByRole("button", { name: "Register app", exact: true }).click(); await actions.waitFor(); }); await step("Reopening the app shows the saved client id", async () => { await actions.click(); await page.getByRole("menuitem", { name: "Edit" }).click(); await page.getByText(`Edit ${appName}`).waitFor(); expect( await page.locator("#oauth-client-id").inputValue(), "the edit persisted to the saved app", ).toBe("client-two"); await page.getByRole("button", { name: "Cancel" }).click(); await actions.waitFor(); }); await step("A rejected removal reports the failure and keeps the app", async () => { await page.route("**/api/oauth/clients/**", async (route) => { if (route.request().method() !== "DELETE") { await route.continue(); return; } await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ _tag: "InternalError", traceId: "oauth-client-remove" }), }); }); await actions.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); await page.getByRole("button", { name: "Remove app" }).click(); await page.getByText(`Failed to remove ${appName}`, { exact: true }).waitFor(); await page.getByRole("heading", { name: `Remove ${appName}?` }).waitFor(); const clients = await Effect.runPromise(client.oauth.listClients()); expect( clients.map((candidate) => String(candidate.slug)), "a rejected removal leaves the registered app in the catalog", ).toContain(appName); await page.getByRole("button", { name: "Cancel" }).click(); await actions.waitFor(); await page.unroute("**/api/oauth/clients/**"); }); await step("Remove the app and confirm", async () => { await actions.click(); await page.getByRole("menuitem", { name: "Remove" }).click(); await page.getByRole("button", { name: "Remove app" }).click(); // The row (and its actions menu) is gone from the picker, and the // empty-state register CTA is back — no app left for this method. await actions.waitFor({ state: "detached" }); await page.getByRole("button", { name: "Register app", exact: true }).waitFor(); // Scope to the modal so the "Removed …" success toast (which // echoes the slug) doesn't count as a lingering picker row. expect( await page.getByRole("dialog").getByText(appDisplayName).count(), "the removed app no longer appears in the picker", ).toBe(0); }); }); // The removal is real, not just visual: the app is gone from the API // (asserted before the finalizer, which would also remove it). const remaining = yield* client.oauth.listClients(); expect( remaining.map((c) => String(c.slug)), "the removed app is gone from the OAuth client catalog", ).not.toContain(appName); }), // Finalizer: never leak the integration or a half-removed app into the // shared selfhost instance, even if a step above failed mid-flow. Effect.gen(function* () { const clients = yield* client.oauth.listClients().pipe(Effect.orElseSucceed(() => [])); for (const c of clients) { if (String(c.slug) === appName) { yield* client.oauth .removeClient({ params: { slug: c.slug }, payload: { owner: c.owner } }) .pipe(Effect.ignore); } } yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); }), ); }),);
// Selfhost (browser): a registered OAuth app is managed entirely from the
// Add-connection modal — there is no separate "OAuth apps" page. The user
// registers a bring-your-own app for an integration, edits its stored client
// id, and removes it, all from the OAuth app picker inside the modal.
//
// The integration only needs to DECLARE an OAuth method for the picker to show;
// registering/editing/removing an app touches stored credentials only and never
// calls the authorization/token endpoints, so a static issuer is enough and no
// OAuth provider is started.
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 { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";
import { visit } from "../src/surfaces/browser";
const api = composePluginApi([openApiHttpPlugin()] as const);
/** Minimal OpenAPI 3 spec — one operation, server never contacted. */
const greetSpec = (baseUrl: string): string =>
JSON.stringify({
openapi: "3.0.3",
info: { title: "OAuth Modal API", version: "1.0.0" },
servers: [{ url: baseUrl }],
paths: {
"/greet": {
get: {
operationId: "getGreeting",
summary: "Return a greeting",
responses: { "200": { description: "A greeting" } },
},
},
},
});
scenario(
"OAuth apps · a registered app is edited and removed from the connect modal",
{ timeout: 180_000 },
Effect.gen(function* () {
const target = yield* Target;
const { client: makeApiClient } = yield* Api;
const browser = yield* Browser;
const identity = yield* target.newIdentity();
const client = yield* makeApiClient(api, identity);
// Selfhost shares the bootstrap-admin identity, so prefix every resource
// with a per-run id to stay out of parallel/repeated runs' way.
const id = randomBytes(4).toString("hex");
const integration = `oauth-modal-scn-${id}`;
const appName = `oauthmodalapp${id}`; // lowercase+digits → slug === appName
// The picker humanizes the slug for display ("oauthmodalappab12" →
// "Oauthmodalappab12"); used to assert the row is gone after removal.
const appDisplayName = appName.charAt(0).toUpperCase() + appName.slice(1);
const specBaseUrl = "http://127.0.0.1:59998"; // never contacted
// Stand up an integration that declares a bring-your-own OAuth2 method.
// The endpoints are inert — app management never calls them.
yield* client.openapi.addSpec({
payload: {
spec: { kind: "blob", value: greetSpec(specBaseUrl) },
slug: integration,
baseUrl: specBaseUrl,
authenticationTemplate: [
{
slug: "oauth",
kind: "oauth2",
authorizationUrl: "https://auth.example/authorize",
tokenUrl: "https://auth.example/token",
scopes: [],
},
],
},
});
yield* Effect.ensuring(
Effect.gen(function* () {
yield* browser.session(identity, async ({ page, step }) => {
const actions = page.getByRole("button", { name: `Actions for ${appName}` });
await step("Open the integration and start a new connection", async () => {
await visit(page, `/integrations/${integration}`);
await page.getByRole("button", { name: "Add connection" }).click();
// OAuth2 is the integration's only method, so the modal opens on
// the OAuth app step with nothing registered yet. (`exact` avoids
// the "Register app for help" tooltip button in the form.)
await page.getByRole("button", { name: "Register app", exact: true }).waitFor();
});
await step("Register a bring-your-own OAuth app", async () => {
await page.getByRole("button", { name: "Register app", exact: true }).click();
await page.locator("#oauth-app-name").fill(appName);
await page.locator("#oauth-client-id").fill("client-one");
await page.locator("#oauth-client-secret").fill("secret-one");
await page.getByRole("button", { name: "Register app", exact: true }).click();
// Back on the picker, the new app is selectable AND manageable —
// the per-app actions menu is what replaced the old apps page.
await actions.waitFor();
});
await step("Edit opens the app prefilled with its stored client id", async () => {
await actions.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByText(`Edit ${appName}`).waitFor();
expect(
await page.locator("#oauth-client-id").inputValue(),
"the edit form prefills the stored client id",
).toBe("client-one");
});
await step("Change the client id and save the edit", async () => {
await page.locator("#oauth-client-id").fill("client-two");
await page.locator("#oauth-client-secret").fill("secret-two");
await page.getByRole("button", { name: "Register app", exact: true }).click();
await actions.waitFor();
});
await step("Reopening the app shows the saved client id", async () => {
await actions.click();
await page.getByRole("menuitem", { name: "Edit" }).click();
await page.getByText(`Edit ${appName}`).waitFor();
expect(
await page.locator("#oauth-client-id").inputValue(),
"the edit persisted to the saved app",
).toBe("client-two");
await page.getByRole("button", { name: "Cancel" }).click();
await actions.waitFor();
});
await step("A rejected removal reports the failure and keeps the app", async () => {
await page.route("**/api/oauth/clients/**", async (route) => {
if (route.request().method() !== "DELETE") {
await route.continue();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ _tag: "InternalError", traceId: "oauth-client-remove" }),
});
});
await actions.click();
await page.getByRole("menuitem", { name: "Remove" }).click();
await page.getByRole("button", { name: "Remove app" }).click();
await page.getByText(`Failed to remove ${appName}`, { exact: true }).waitFor();
await page.getByRole("heading", { name: `Remove ${appName}?` }).waitFor();
const clients = await Effect.runPromise(client.oauth.listClients());
expect(
clients.map((candidate) => String(candidate.slug)),
"a rejected removal leaves the registered app in the catalog",
).toContain(appName);
await page.getByRole("button", { name: "Cancel" }).click();
await actions.waitFor();
await page.unroute("**/api/oauth/clients/**");
});
await step("Remove the app and confirm", async () => {
await actions.click();
await page.getByRole("menuitem", { name: "Remove" }).click();
await page.getByRole("button", { name: "Remove app" }).click();
// The row (and its actions menu) is gone from the picker, and the
// empty-state register CTA is back — no app left for this method.
await actions.waitFor({ state: "detached" });
await page.getByRole("button", { name: "Register app", exact: true }).waitFor();
// Scope to the modal so the "Removed …" success toast (which
// echoes the slug) doesn't count as a lingering picker row.
expect(
await page.getByRole("dialog").getByText(appDisplayName).count(),
"the removed app no longer appears in the picker",
).toBe(0);
});
});
// The removal is real, not just visual: the app is gone from the API
// (asserted before the finalizer, which would also remove it).
const remaining = yield* client.oauth.listClients();
expect(
remaining.map((c) => String(c.slug)),
"the removed app is gone from the OAuth client catalog",
).not.toContain(appName);
}),
// Finalizer: never leak the integration or a half-removed app into the
// shared selfhost instance, even if a step above failed mid-flow.
Effect.gen(function* () {
const clients = yield* client.oauth.listClients().pipe(Effect.orElseSucceed(() => []));
for (const c of clients) {
if (String(c.slug) === appName) {
yield* client.oauth
.removeClient({ params: { slug: c.slug }, payload: { owner: c.owner } })
.pipe(Effect.ignore);
}
}
yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore);
}),
);
}),
);