406 lines · 14.0 KB · TypeScript
RawDownload
import { describe, expect, it } from "@effect/vitest";import { Effect, Predicate } from "effect";import type * as Tracer from "effect/Tracer"; import {  AuthTemplateSlug,  ConnectionName,  IntegrationSlug,  OAuthClientSlug,  ToolAddress,  ToolName,} from "./ids";import {  firstPartyOAuthClientSlug,  type FirstPartyOAuthClientConfig,  type OAuthStartError,} from "./oauth-client";import { definePlugin } from "./plugin";import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";import { scopesFromAuthorizeUrl, serveOAuthTestServer } from "./testing/oauth-test-server"; // First-party OAuth clients: host-operated apps declared in executor config// (`firstPartyOAuthClients`), addressed as `first-party:<name>`. Resolved from// config, never storage — these tests prove the whole lifecycle (start →// complete → execute → refresh) runs off the config-declared identity, that the// client CRUD surface rejects the reserved namespace, and that listings project// the app without ever having written its secret to a credential provider. const INTEG = IntegrationSlug.make("acme");const TEMPLATE = AuthTemplateSlug.make("oauth");const FIRST_PARTY = firstPartyOAuthClientSlug("acme"); const makeRecordingTracer = (spans: Map<string, Map<string, unknown>>): Tracer.Tracer => ({  span: (options) => {    const attributes = new Map<string, unknown>();    spans.set(options.name, attributes);    let status: Tracer.SpanStatus = { _tag: "Started", startTime: options.startTime };    return {      _tag: "Span",      name: options.name,      spanId: "0000000000000001",      traceId: "00000000000000000000000000000001",      parent: options.parent,      annotations: options.annotations,      get status() {        return status;      },      attributes,      links: options.links,      sampled: options.sampled,      kind: options.kind,      end: (endTime, exit) => {        status = { _tag: "Ended", startTime: options.startTime, endTime, exit };      },      attribute: (key, value) => {        attributes.set(key, value);      },      event: () => undefined,      addLinks: () => undefined,    };  },}); const oauthPlugin = definePlugin(() => ({  id: "acme" as const,  storage: () => ({}),  resolveTools: () =>    Effect.succeed({      tools: [{ name: ToolName.make("whoami"), description: "whoami" }],    }),  describeAuthMethods: (record) => {    const config = record.config as { readonly scopes?: readonly string[] } | null;    return [      {        id: "oauth",        label: "OAuth2",        kind: "oauth" as const,        template: String(TEMPLATE),        oauth: { scopes: config?.scopes ?? [] },      },    ];  },  invokeTool: ({ credential }) => Effect.succeed({ token: credential.value }),  checkHealth: ({ credential }) =>    Effect.succeed({      status: credential.value === null ? "expired" : "healthy",      checkedAt: Date.now(),    }),  extension: (ctx) => ({    seed: (scopes: readonly string[] = []) =>      ctx.core.integrations.register({        slug: INTEG,        description: "Acme",        config: { scopes },      }),  }),}))(); const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; const firstPartyClientFor = (server: {  readonly authorizationEndpoint: string;  readonly tokenEndpoint: string;}): FirstPartyOAuthClientConfig => ({  name: "acme",  authorizationUrl: server.authorizationEndpoint,  tokenUrl: server.tokenEndpoint,  clientId: "test-client",  clientSecret: "test-secret",  integrations: [INTEG],}); describe("first-party oauth clients", () => {  it.effect(    "start → complete through a config-declared client mints an executable connection",    () => {      const spans = new Map<string, Map<string, unknown>>();      return Effect.scoped(        Effect.gen(function* () {          const server = yield* serveOAuthTestServer({ scopes: ["read"] });          const { executor } = yield* makeTestWorkspaceHarness({            plugins,            firstPartyOAuthClients: [firstPartyClientFor(server)],          });          yield* executor.acme.seed();           // No createClient call — the app exists purely in config.          const started = yield* executor.oauth.start({            owner: "org",            client: FIRST_PARTY,            clientOwner: "org",            name: ConnectionName.make("main-account"),            integration: INTEG,            template: TEMPLATE,          });          expect(started.status).toBe("redirect");          expect(            spans.get("test.oauth.first_party")?.get("executor.oauth.client_first_party"),          ).toBe(true);          if (started.status !== "redirect") return;           const callback = yield* server.completeAuthorizationCodeFlow({            authorizationUrl: started.authorizationUrl,          });          const connection = yield* executor.oauth.complete({            state: started.state,            code: callback.code,          });          expect(String(connection.address)).toBe("tools.acme.org.mainAccount");          expect(            spans.get("executor.oauth.complete")?.get("executor.oauth.client_first_party"),          ).toBe(true);           const out = (yield* executor.execute(            ToolAddress.make("tools.acme.org.mainAccount.whoami"),            {},          )) as { token: string };          expect(out.token).toMatch(/^at_/);          expect(yield* server.acceptsAccessToken(out.token)).toBe(true);        }),      ).pipe(        Effect.withSpan("test.oauth.first_party"),        Effect.withTracer(makeRecordingTracer(spans)),      );    },  );   it.effect("an authorization scope override supports scope-less provider app tokens", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["repo"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [{ ...firstPartyClientFor(server), authorizationScopes: [] }],        });        yield* executor.acme.seed(["repo"]);         const started = yield* executor.oauth.start({          owner: "org",          client: FIRST_PARTY,          clientOwner: "org",          name: ConnectionName.make("github-app"),          integration: INTEG,          template: TEMPLATE,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;        expect(scopesFromAuthorizeUrl(started.authorizationUrl)).toEqual([]);         const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const connection = yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });        expect(connection.oauthScope).toBeNull();      }),    ),  );   it.effect("refresh resolves the config-declared client (no oauth_client row exists)", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const harness = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [firstPartyClientFor(server)],        });        const { executor, config } = harness;        yield* executor.acme.seed();         const started = yield* executor.oauth.start({          owner: "org",          client: FIRST_PARTY,          clientOwner: "org",          name: ConnectionName.make("main"),          integration: INTEG,          template: TEMPLATE,        });        if (started.status !== "redirect") return;        const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        yield* executor.oauth.complete({ state: started.state, code: callback.code });         const firstToken = (yield* executor.execute(          ToolAddress.make("tools.acme.org.main.whoami"),          {},        )) as { token: string };         // Force expiry so the next resolve refreshes through the config client.        yield* Effect.promise(() =>          config.db.updateMany("connection", {            where: (b) => b("name", "=", "main"),            set: { expires_at: Date.now() - 60_000 },          }),        );         const refreshedToken = (yield* executor.execute(          ToolAddress.make("tools.acme.org.main.whoami"),          {},        )) as { token: string };        expect(refreshedToken.token).not.toBe(firstToken.token);        expect(yield* server.acceptsAccessToken(refreshedToken.token)).toBe(true);      }),    ),  );   it.effect("listClients projects the first-party app ahead of stored rows, secretless", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [            {              ...firstPartyClientFor(server),              resource: server.resourceUrl,              allowedScopes: ["openid", "read"],            },          ],        });        yield* executor.acme.seed();         yield* executor.oauth.createClient({          owner: "org",          slug: OAuthClientSlug.make("byo-app"),          authorizationUrl: server.authorizationEndpoint,          tokenUrl: server.tokenEndpoint,          grant: "authorization_code",          clientId: "byo-client",          clientSecret: "byo-secret",        });         const clients = yield* executor.oauth.listClients();        expect(clients.map((c) => String(c.slug))).toEqual(["first-party:acme", "byo-app"]);        const firstParty = clients[0]!;        expect(firstParty.origin).toEqual({          kind: "first_party",          integrations: [INTEG],          allowedScopes: ["openid", "read"],        });        expect(firstParty.clientId).toBe("test-client");        expect(firstParty.resource).toBe(server.resourceUrl);        expect("clientSecret" in firstParty).toBe(false);      }),    ),  );   it.effect("a scope-limited first-party app rejects an integration outside its policy", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read", "write"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [{ ...firstPartyClientFor(server), allowedScopes: ["read"] }],        });        yield* executor.acme.seed(["write"]);         const error = yield* executor.oauth          .start({            owner: "org",            client: FIRST_PARTY,            clientOwner: "org",            name: ConnectionName.make("blocked"),            integration: INTEG,            template: TEMPLATE,          })          .pipe(Effect.flip);         expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);        const startError = error as OAuthStartError;        expect(startError.message).toContain("not enabled for integration acme");      }),    ),  );   it.effect("createClient and removeClient reject the reserved first-party namespace", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [firstPartyClientFor(server)],        });         const createError = yield* executor.oauth          .createClient({            owner: "org",            slug: FIRST_PARTY,            authorizationUrl: server.authorizationEndpoint,            tokenUrl: server.tokenEndpoint,            grant: "authorization_code",            clientId: "impostor",            clientSecret: "impostor-secret",          })          .pipe(Effect.flip);        expect(createError.message).toContain("reserved first-party namespace");         const removeError = yield* executor.oauth          .removeClient("org", FIRST_PARTY)          .pipe(Effect.flip);        expect(removeError.message).toContain("cannot be removed");      }),    ),  );   it.effect("start with an undeclared first-party slug fails as client-not-found", () =>    Effect.scoped(      Effect.gen(function* () {        yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({ plugins });        yield* executor.acme.seed();         const error = yield* executor.oauth          .start({            owner: "org",            client: firstPartyOAuthClientSlug("nope"),            clientOwner: "org",            name: ConnectionName.make("main"),            integration: INTEG,            template: TEMPLATE,          })          .pipe(Effect.flip);        // `OAuthStartError` carries a typed `message`; the `Predicate.isTagged`        // guard narrows the union so this read is on a typed failure.        expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true);        const startError = error as OAuthStartError;        expect(startError.message).toContain("not found");      }),    ),  );   it.effect("a Personal connection can mint through a first-party app", () =>    Effect.scoped(      Effect.gen(function* () {        const server = yield* serveOAuthTestServer({ scopes: ["read"] });        const { executor } = yield* makeTestWorkspaceHarness({          plugins,          firstPartyOAuthClients: [firstPartyClientFor(server)],        });        yield* executor.acme.seed();         const started = yield* executor.oauth.start({          owner: "user",          client: FIRST_PARTY,          clientOwner: "org",          name: ConnectionName.make("mine"),          integration: INTEG,          template: TEMPLATE,        });        expect(started.status).toBe("redirect");        if (started.status !== "redirect") return;        const callback = yield* server.completeAuthorizationCodeFlow({          authorizationUrl: started.authorizationUrl,        });        const connection = yield* executor.oauth.complete({          state: started.state,          code: callback.code,        });        expect(String(connection.address)).toBe("tools.acme.user.mine");      }),    ),  );});