Skip to content

Commit d259b45

Browse files
committed
Restore OpenAPI OAuth coverage
1 parent 92235b0 commit d259b45

4 files changed

Lines changed: 2473 additions & 0 deletions

File tree

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// ---------------------------------------------------------------------------
2+
// End-to-end test for the OAuth2 `client_credentials` grant on an OpenAPI
3+
// source. A spec that declares ONLY a `clientCredentials` flow (no
4+
// authorizationCode, no user-interactive popup, no PKCE) mints a completed
5+
// Connection through the shared OAuth service; `ctx.connections.accessToken`
6+
// then resolves the bearer at invoke time.
7+
// ---------------------------------------------------------------------------
8+
9+
import { expect, layer } from "@effect/vitest";
10+
import { Effect, Layer, Ref, Schema } from "effect";
11+
import {
12+
HttpApi,
13+
HttpApiBuilder,
14+
HttpApiEndpoint,
15+
HttpApiGroup,
16+
OpenApi,
17+
} from "effect/unstable/httpapi";
18+
import {
19+
FetchHttpClient,
20+
HttpRouter,
21+
HttpServer,
22+
HttpServerRequest,
23+
HttpServerResponse,
24+
} from "effect/unstable/http";
25+
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
26+
27+
import {
28+
ConnectionId,
29+
createExecutor,
30+
definePlugin,
31+
Scope,
32+
ScopeId,
33+
SecretId,
34+
SetSecretInput,
35+
type InvokeOptions,
36+
makeTestConfig,
37+
type SecretProvider,
38+
} from "@executor-js/sdk";
39+
import { serveTestHttpApp } from "@executor-js/sdk/testing";
40+
41+
import { openApiPlugin } from "./plugin";
42+
import { OAuth2SourceConfig, OpenApiSourceBindingInput } from "./types";
43+
44+
const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
45+
46+
class OpenApiClientCredentialsTestSetupError extends Schema.TaggedErrorClass<OpenApiClientCredentialsTestSetupError>()(
47+
"OpenApiClientCredentialsTestSetupError",
48+
{
49+
message: Schema.String,
50+
},
51+
) {}
52+
53+
// ---------------------------------------------------------------------------
54+
// Test API — single endpoint that echoes the Authorization header.
55+
// ---------------------------------------------------------------------------
56+
57+
const EchoHeaders = Schema.Struct({
58+
authorization: Schema.optional(Schema.String),
59+
});
60+
type EchoHeaders = typeof EchoHeaders.Type;
61+
62+
const ItemsGroup = HttpApiGroup.make("items").add(
63+
HttpApiEndpoint.get("echoHeaders", "/echo-headers", { success: EchoHeaders }),
64+
);
65+
66+
const TestApi = HttpApi.make("testApi").add(ItemsGroup);
67+
const specJson = JSON.stringify(OpenApi.fromApi(TestApi));
68+
69+
const ItemsGroupLive = HttpApiBuilder.group(TestApi, "items", (handlers) =>
70+
handlers.handle("echoHeaders", () =>
71+
Effect.gen(function* () {
72+
const req = yield* HttpServerRequest.HttpServerRequest;
73+
return EchoHeaders.make({
74+
authorization: req.headers["authorization"],
75+
});
76+
}),
77+
),
78+
);
79+
80+
const ApiLive = HttpApiBuilder.layer(TestApi).pipe(Layer.provide(ItemsGroupLive));
81+
82+
const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLogger: true }).pipe(
83+
Layer.provideMerge(NodeHttpServer.layerTest),
84+
);
85+
86+
type TokenCall = {
87+
readonly grantType: string | null;
88+
readonly clientId: string | null;
89+
readonly clientSecret: string | null;
90+
readonly scope: string | null;
91+
};
92+
93+
const serveClientCredentialsTokenEndpoint = (args: {
94+
readonly accessTokens: readonly string[];
95+
readonly expiresIn?: number;
96+
}) =>
97+
Effect.gen(function* () {
98+
const calls = yield* Ref.make<readonly TokenCall[]>([]);
99+
let callIndex = 0;
100+
const server = yield* serveTestHttpApp((request) =>
101+
Effect.gen(function* () {
102+
const params = new URLSearchParams(yield* request.text);
103+
yield* Ref.update(calls, (all) => [
104+
...all,
105+
{
106+
grantType: params.get("grant_type"),
107+
clientId: params.get("client_id"),
108+
clientSecret: params.get("client_secret"),
109+
scope: params.get("scope"),
110+
},
111+
]);
112+
const token =
113+
args.accessTokens[Math.min(callIndex, args.accessTokens.length - 1)] ?? "unknown";
114+
callIndex += 1;
115+
const body: Record<string, unknown> = {
116+
access_token: token,
117+
token_type: "Bearer",
118+
};
119+
if (typeof args.expiresIn === "number") body.expires_in = args.expiresIn;
120+
return HttpServerResponse.jsonUnsafe(body);
121+
}).pipe(
122+
Effect.catch(() =>
123+
Effect.succeed(HttpServerResponse.text("token fixture request failed", { status: 500 })),
124+
),
125+
),
126+
);
127+
128+
return {
129+
tokenUrl: server.url("/token"),
130+
calls: Ref.get(calls),
131+
} as const;
132+
});
133+
134+
// ---------------------------------------------------------------------------
135+
// Tests
136+
// ---------------------------------------------------------------------------
137+
138+
layer(TestLayer)("OpenAPI client_credentials OAuth", (it) => {
139+
it.effect("startOAuth exchanges tokens inline and makes them usable at invoke time", () =>
140+
Effect.gen(function* () {
141+
const secretStore = new Map<string, string>();
142+
const key = (scope: string, id: string) => `${scope}:${id}`;
143+
const memoryProvider: SecretProvider = {
144+
key: "memory",
145+
writable: true,
146+
get: (id, scope) => Effect.sync(() => secretStore.get(key(scope, id)) ?? null),
147+
set: (id, value, scope) =>
148+
Effect.sync(() => {
149+
secretStore.set(key(scope, id), value);
150+
}),
151+
delete: (id, scope) => Effect.sync(() => secretStore.delete(key(scope, id))),
152+
};
153+
const memorySecretsPlugin = definePlugin(() => ({
154+
id: "memory-secrets" as const,
155+
storage: () => ({}),
156+
secretProviders: [memoryProvider],
157+
}));
158+
const clientLayer = FetchHttpClient.layer;
159+
const server = yield* HttpServer.HttpServer;
160+
const address = server.address;
161+
if (!("port" in address)) {
162+
return yield* new OpenApiClientCredentialsTestSetupError({
163+
message: "Test server must bind to TCP",
164+
});
165+
}
166+
const baseUrl = `http://127.0.0.1:${address.port}`;
167+
const plugins = [
168+
openApiPlugin({ httpClientLayer: clientLayer }),
169+
memorySecretsPlugin(),
170+
] as const;
171+
const config = makeTestConfig({ plugins });
172+
173+
const now = new Date();
174+
const orgScope = Scope.make({
175+
id: ScopeId.make("org"),
176+
name: "acme-org",
177+
createdAt: now,
178+
});
179+
const userScope = Scope.make({
180+
id: ScopeId.make("user-alice"),
181+
name: "alice",
182+
createdAt: now,
183+
});
184+
185+
const adminExec = yield* createExecutor({
186+
...config,
187+
scopes: [orgScope],
188+
plugins,
189+
onElicitation: "accept-all",
190+
});
191+
const userExec = yield* createExecutor({
192+
...config,
193+
scopes: [userScope, orgScope],
194+
plugins,
195+
onElicitation: "accept-all",
196+
});
197+
198+
// Admin seeds the shared client_id + client_secret at the org.
199+
yield* adminExec.secrets.set(
200+
SetSecretInput.make({
201+
id: SecretId.make("petstore_client_id"),
202+
scope: orgScope.id,
203+
name: "Petstore Client ID",
204+
value: "client-abc",
205+
}),
206+
);
207+
yield* adminExec.secrets.set(
208+
SetSecretInput.make({
209+
id: SecretId.make("petstore_client_secret"),
210+
scope: orgScope.id,
211+
name: "Petstore Client Secret",
212+
value: "secret-xyz",
213+
}),
214+
);
215+
216+
const tokenEndpoint = yield* serveClientCredentialsTokenEndpoint({
217+
accessTokens: ["alice-token-1"],
218+
});
219+
220+
// ------------------------------------------------------------
221+
// Shared OAuth start for clientCredentials: no authorizationUrl,
222+
// no popup, no complete. The OAuth service exchanges tokens
223+
// inline and creates the Connection.
224+
// ------------------------------------------------------------
225+
const connectionId = "openapi-oauth2-app-petstore";
226+
const started = yield* userExec.oauth.start({
227+
endpoint: tokenEndpoint.tokenUrl,
228+
redirectUrl: tokenEndpoint.tokenUrl,
229+
connectionId,
230+
tokenScope: String(userScope.id),
231+
pluginId: "openapi",
232+
identityLabel: "Petstore OAuth",
233+
strategy: {
234+
kind: "client-credentials",
235+
tokenEndpoint: tokenEndpoint.tokenUrl,
236+
clientIdSecretId: "petstore_client_id",
237+
clientSecretSecretId: "petstore_client_secret",
238+
scopes: ["data"],
239+
},
240+
});
241+
242+
const completedConnection = started.completedConnection;
243+
if (!completedConnection) {
244+
return yield* new OpenApiClientCredentialsTestSetupError({
245+
message: "Expected completed clientCredentials connection",
246+
});
247+
}
248+
const oauth2 = OAuth2SourceConfig.make({
249+
kind: "oauth2",
250+
securitySchemeName: "oauth2",
251+
flow: "clientCredentials",
252+
tokenUrl: tokenEndpoint.tokenUrl,
253+
authorizationUrl: null,
254+
clientIdSlot: "oauth2:oauth2:client-id",
255+
clientSecretSlot: "oauth2:oauth2:client-secret",
256+
connectionSlot: "oauth2:oauth2:connection",
257+
scopes: ["data"],
258+
});
259+
expect(completedConnection.connectionId).toBe(connectionId);
260+
261+
// Token endpoint call is RFC 6749 §4.4 compliant.
262+
const calls = yield* tokenEndpoint.calls;
263+
expect(calls).toHaveLength(1);
264+
expect(calls[0]!.grantType).toBe("client_credentials");
265+
expect(calls[0]!.clientId).toBe("client-abc");
266+
expect(calls[0]!.clientSecret).toBe("secret-xyz");
267+
expect(calls[0]!.scope).toBe("data");
268+
269+
// Add the source with source-owned OAuth structure, then bind the
270+
// per-user connection into the configured slot.
271+
yield* userExec.openapi.addSpec({
272+
spec: specJson,
273+
scope: userScope.id,
274+
namespace: "petstore",
275+
baseUrl,
276+
oauth2,
277+
});
278+
yield* userExec.openapi.setSourceBinding(
279+
OpenApiSourceBindingInput.make({
280+
sourceId: "petstore",
281+
sourceScope: userScope.id,
282+
scope: userScope.id,
283+
slot: oauth2.connectionSlot,
284+
value: {
285+
kind: "connection",
286+
connectionId: ConnectionId.make(completedConnection.connectionId),
287+
},
288+
}),
289+
);
290+
// Invoking the tool injects the freshly-minted bearer via
291+
// ctx.connections.accessToken.
292+
const result = (yield* userExec.tools.invoke(
293+
"petstore.items.echoHeaders",
294+
{},
295+
autoApprove,
296+
)) as {
297+
data: { authorization?: string } | null;
298+
error: unknown;
299+
};
300+
expect(result.error).toBeNull();
301+
expect(result.data?.authorization).toBe("Bearer alice-token-1");
302+
303+
// The connection lives at the innermost (user) scope, which
304+
// preserves per-user credential resolution: if each user has
305+
// their own `dealcloud_client_id`/`dealcloud_client_secret`
306+
// shadowed at their user scope, each user mints their own
307+
// token. A single shared connection slot still lets every caller
308+
// reach the right physical row through scoped credential bindings.
309+
const userConnections = yield* userExec.connections.list();
310+
const connection = userConnections.find((c) => c.id === completedConnection.connectionId);
311+
expect(connection).toBeDefined();
312+
expect(String(connection?.scopeId)).toBe("user-alice");
313+
expect(connection?.provider).toBe("oauth2");
314+
// Stable id derived from sourceId — no UUID-per-click churn.
315+
expect(completedConnection.connectionId).toBe("openapi-oauth2-app-petstore");
316+
317+
// Access-token secret is owned by the connection and filtered
318+
// out of the user-facing secret list.
319+
const userSecretIds = new Set((yield* userExec.secrets.list()).map((s) => String(s.id)));
320+
expect(userSecretIds).toContain("petstore_client_id");
321+
expect(userSecretIds).toContain("petstore_client_secret");
322+
expect(userSecretIds).not.toContain(`${completedConnection.connectionId}.access_token`);
323+
324+
// Admin scope sees neither alice's connection nor her token.
325+
const adminSecretIds = new Set((yield* adminExec.secrets.list()).map((s) => String(s.id)));
326+
expect(adminSecretIds).not.toContain(`${completedConnection.connectionId}.access_token`);
327+
}),
328+
);
329+
});

0 commit comments

Comments
 (0)