324 lines · 12.4 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";import * as Option from "effect/Option";import {  AuthTemplateSlug,  ConnectionAddress,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  ProviderKey,  type Connection,  type OAuthClientSummary,} from "@executor-js/sdk/shared"; import {  missingScopes,  oauthReconnectPayload,  reconnectClientsView,  reconnectMode,  reconnectRoute,  reconnectStoredClient,  reconsentRequiredScopes,  retryReconnectClientsOnMenuOpen,} from "./oauth-reconnect"; const connection = (overrides: Partial<Connection> = {}): Connection => ({  owner: "user",  name: ConnectionName.make("personal-github"),  integration: IntegrationSlug.make("github"),  template: AuthTemplateSlug.make("oauth"),  provider: ProviderKey.make("default"),  address: ConnectionAddress.make("tools.github.user.personal-github"),  identityLabel: "Personal GitHub",  expiresAt: 123,  oauthClient: OAuthClientSlug.make("github-app"),  ...overrides,}); describe("reconnectMode (OAuth vs non-OAuth branch)", () => {  // The single field `oauthClient` decides the path: OAuth connections must  // re-consent (a refresh cannot widen scopes / fails with no refresh token).  it("returns 'oauth' when the connection carries an oauthClient slug", () => {    expect(reconnectMode(connection())).toBe("oauth");  });   it("returns 'refresh' when oauthClient is null (static credential)", () => {    expect(reconnectMode(connection({ oauthClient: null }))).toBe("refresh");  });   it("returns 'refresh' when oauthClient is absent", () => {    const { oauthClient: _drop, ...rest } = connection();    expect(reconnectMode(rest as Connection)).toBe("refresh");  });}); describe("oauthReconnectPayload (re-mint the SAME connection)", () => {  // The payload re-runs oauth.start with the SAME owner/integration/name so the  // backend mint upserts the existing row (widened union + fresh refresh token).  it("builds the start payload from an OAuth connection's own fields", () => {    const payload = oauthReconnectPayload(connection());    expect(payload).not.toBeNull();    expect(payload!.client).toBe(OAuthClientSlug.make("github-app"));    expect(payload!.owner).toBe("user");    expect(payload!.name).toBe(ConnectionName.make("personal-github"));    expect(payload!.integration).toBe(IntegrationSlug.make("github"));    expect(payload!.template).toBe(AuthTemplateSlug.make("oauth"));    expect(payload!.identityLabel).toBe("Personal GitHub");  });   it("maps a null identityLabel to undefined (optional payload field)", () => {    const payload = oauthReconnectPayload(connection({ identityLabel: null }));    expect(payload!.identityLabel).toBeUndefined();  });   it("returns null for a non-OAuth connection (no oauthClient)", () => {    expect(oauthReconnectPayload(connection({ oauthClient: null }))).toBeNull();  });}); const clientSummary = (overrides: Partial<OAuthClientSummary> = {}): OAuthClientSummary => ({  owner: "user",  slug: OAuthClientSlug.make("github-app"),  grant: "authorization_code",  authorizationUrl: "https://auth.example.com/authorize",  tokenUrl: "https://auth.example.com/token",  resource: null,  clientId: "client-123",  origin: { kind: "manual", integration: null },  ...overrides,}); describe("reconnectStoredClient (resolve a connection's stored app)", () => {  it("finds the stored row by slug and the app's stored owner", () => {    const stored = clientSummary();    expect(reconnectStoredClient([clientSummary({ owner: "org" }), stored], connection())).toBe(      stored,    );  });   it("matches against oauthClientOwner when the app is shared (org app, user connection)", () => {    const shared = clientSummary({ owner: "org" });    expect(reconnectStoredClient([shared], connection({ oauthClientOwner: "org" }))).toBe(shared);    // Without the stored app owner, the connection's own owner is the key.    expect(reconnectStoredClient([shared], connection())).toBeUndefined();  });   it("matches a first-party app on slug alone (config-declared, deployment-scoped)", () => {    const firstParty = clientSummary({      owner: "org",      slug: OAuthClientSlug.make("first-party:github"),      origin: { kind: "first_party" },    });    expect(      reconnectStoredClient(        [firstParty],        connection({ oauthClient: OAuthClientSlug.make("first-party:github") }),      ),    ).toBe(firstParty);  });   it("is undefined for a non-OAuth connection and for a binding whose row is gone", () => {    expect(      reconnectStoredClient([clientSummary()], connection({ oauthClient: null })),    ).toBeUndefined();    expect(reconnectStoredClient([], connection())).toBeUndefined();  });}); describe("reconnectRoute (where an OAuth Reconnect goes)", () => {  const dcrBinding = clientSummary({    origin: { kind: "dynamic_client_registration", integration: null },  });   // The summaries have not loaded → the stored binding is UNKNOWN, and no  // route may be chosen. The old routing chose "direct" here, permanently  // dead-ending an origin-drifted DCR client exactly as #1542 described.  it("waits (never chooses direct) while the client summaries are loading", () => {    expect(reconnectRoute(undefined, connection(), true)).toEqual({ kind: "unknown" });    expect(reconnectRoute(undefined, connection(), false)).toEqual({ kind: "unknown" });  });   // The BINDING's origin routes, not the method's capability flags: the  // automatic flow can probe the token URL even when the method declares no  // discovery/DCR support.  it("routes a DCR-origin binding to the automatic path regardless of method capability", () => {    expect(reconnectRoute([dcrBinding], connection(), false)).toEqual({      kind: "automatic",      stored: dcrBinding,    });    expect(reconnectRoute([dcrBinding], connection(), true)).toEqual({      kind: "automatic",      stored: dcrBinding,    });  });   // A manual (static/BYO) or first-party binding must keep the direct  // stored-client path — re-registering would silently rebind the connection.  it("keeps a manual (static/BYO) binding on the direct path even on a capable method", () => {    expect(reconnectRoute([clientSummary()], connection(), true)).toEqual({ kind: "direct" });  });   it("keeps a first-party binding on the direct path", () => {    expect(      reconnectRoute([clientSummary({ origin: { kind: "first_party" } })], connection(), true),    ).toEqual({ kind: "direct" });  });   // A binding whose row is GONE has nothing to start directly against, so  // re-registration is its only recovery — when the method supports the  // automatic flow at all.  it("re-registers a gone binding when the method supports the automatic flow", () => {    expect(reconnectRoute([], connection(), true)).toEqual({      kind: "automatic",      stored: undefined,    });    expect(reconnectRoute([], connection(), false)).toEqual({ kind: "direct" });  });}); describe("reconnectClientsView (Reconnect availability from the summaries query)", () => {  const dcrBinding = clientSummary({    origin: { kind: "dynamic_client_registration", integration: null },  });  const loaded = AsyncResult.success<readonly OAuthClientSummary[], string>([dcrBinding]);   it("is ready with the loaded summaries on success", () => {    expect(reconnectClientsView(loaded)).toEqual({ kind: "ready", clients: [dcrBinding] });  });   // Stale data NEVER routes: a binding changed since the retained snapshot can  // misroute (repeat the origin-drift dead end, or treat a client absent from  // the snapshot as vanished and rebind it). A failed refresh is failed even  // with previousSuccess — disabled, surfaced, and retried on menu open like  // any other failure.  it("is failed when a refresh fails, even with previous successful data", () => {    const failedRefresh = AsyncResult.fail("listClients failed", {      previousSuccess: Option.some(loaded),    });    const view = reconnectClientsView(failedRefresh);    expect(view).toEqual({ kind: "failed" });    // The failed view recovers like any other: opening the menu retries.    let calls = 0;    retryReconnectClientsOnMenuOpen(true, view, () => calls++);    expect(calls).toBe(1);  });   it("is loading while the first load is in flight", () => {    expect(reconnectClientsView(AsyncResult.initial())).toEqual({ kind: "loading" });    expect(reconnectClientsView(AsyncResult.initial(true))).toEqual({ kind: "loading" });  });   it("is failed for a first-load failure (never produced data)", () => {    expect(reconnectClientsView(AsyncResult.fail("listClients failed"))).toEqual({      kind: "failed",    });  });   it("reads a failure that is retrying as loading, not failed", () => {    expect(reconnectClientsView(AsyncResult.fail("listClients failed", { waiting: true }))).toEqual(      { kind: "loading" },    );    // A retry in flight is loading even when previous data is retained.    expect(      reconnectClientsView(        AsyncResult.fail("listClients failed", {          waiting: true,          previousSuccess: Option.some(loaded),        }),      ),    ).toEqual({ kind: "loading" });  });}); describe("retryReconnectClientsOnMenuOpen (recovery for a failed summaries query)", () => {  const counter = () => {    let calls = 0;    return { retry: () => calls++, calls: () => calls };  };   it("refetches when the menu OPENS on a failed view", () => {    const spy = counter();    retryReconnectClientsOnMenuOpen(true, { kind: "failed" }, spy.retry);    expect(spy.calls()).toBe(1);  });   it("does not refetch on close, while loading, or with data", () => {    const spy = counter();    retryReconnectClientsOnMenuOpen(false, { kind: "failed" }, spy.retry);    retryReconnectClientsOnMenuOpen(true, { kind: "loading" }, spy.retry);    retryReconnectClientsOnMenuOpen(true, { kind: "ready", clients: [] }, spy.retry);    expect(spy.calls()).toBe(0);  });}); describe("missingScopes (Part 2 informational subset warning)", () => {  // The app's scopes are a STRICT subset of the integration's → list what's  // missing, in the integration's declared order.  it("lists scopes the integration declares that the app does not grant", () => {    expect(missingScopes(["a", "b", "c"], ["a"])).toEqual(["b", "c"]);  });   it("is empty when the app covers everything the integration declares", () => {    expect(missingScopes(["a", "b"], ["a", "b"])).toEqual([]);  });   it("is empty when the app is a SUPERSET of the integration's scopes", () => {    expect(missingScopes(["a"], ["a", "b", "c"])).toEqual([]);  });   it("is empty when the integration declares no scopes", () => {    expect(missingScopes(undefined, ["a"])).toEqual([]);    expect(missingScopes([], ["a"])).toEqual([]);  });   it("treats undefined/empty client scopes as granting nothing", () => {    expect(missingScopes(["a", "b"], undefined)).toEqual(["a", "b"]);    expect(missingScopes(["a", "b"], [])).toEqual(["a", "b"]);  });   it("normalizes whitespace and dedupes before comparing (sets, not lists)", () => {    expect(missingScopes([" a ", "a", "b", ""], ["a"])).toEqual(["b"]);    expect(missingScopes(["a", "b"], [" a ", "a", ""])).toEqual(["b"]);  });   it("treats Google's expanded userinfo scopes as OIDC profile/email grants", () => {    expect(      missingScopes(        ["profile", "email", "https://www.googleapis.com/auth/calendar"],        [          "https://www.googleapis.com/auth/userinfo.profile",          "https://www.googleapis.com/auth/userinfo.email",          "https://www.googleapis.com/auth/calendar",          "openid",        ],      ),    ).toEqual([]);  });}); describe("reconsentRequiredScopes", () => {  it("treats spec-derived oauth scopes as NOT required (opportunistic catalog)", () => {    // An OpenAPI integration (e.g. PostHog) declares the full per-operation scope    // catalog. A narrower grant is healthy and must not nag for reconnect.    expect(      reconsentRequiredScopes({        source: "spec",        oauth: { scopes: ["insight:read", "insight:write", "person:read"] },      }),    ).toBeUndefined();  });   it("keeps custom (user-configured) oauth scopes required", () => {    expect(      reconsentRequiredScopes({        source: "custom",        oauth: { scopes: ["read", "write"] },      }),    ).toEqual(["read", "write"]);  });   it("returns undefined when there is no oauth method", () => {    expect(reconsentRequiredScopes(undefined)).toBeUndefined();  });});