mirror of
https://gh-proxy.org/https://github.com/klinsmaya/open-connector.git
synced 2026-11-08 01:16:52 +08:00
fix: preserve API error contracts for HTTP admission denials (#644)
Return HTTP admission denials through the existing OAuth connection-request failure callback and preserve console versus runtime error response formats, including retry hints. Add seven regression cases for token-exchange and credential-validation denials, callbacks with and without return URIs, request state, blocked egress, unsaved credentials, callback replay, and route-specific envelopes. Validation: 222 targeted tests, fix-check, GitHub CI, and package build/installation/startup/Bun verification passed.
This commit is contained in:
@@ -396,8 +396,14 @@ export class OAuthFlowService {
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderDispatchRequestError) {
|
||||
if (request) await this.requests.fail(request.connectionRequestId, "rate_limited", error.message);
|
||||
throw error;
|
||||
if (!request) throw error;
|
||||
await this.requests.fail(request.connectionRequestId, "rate_limited", error.message);
|
||||
throw new OAuthCallbackError(
|
||||
"rate_limited",
|
||||
error.message,
|
||||
callbackReturnUri(request, "error", "rate_limited", error.message),
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (request) {
|
||||
const code =
|
||||
|
||||
@@ -420,12 +420,16 @@ export class ConnectServer {
|
||||
else app.notFound(notFound);
|
||||
app.onError((error, context) => {
|
||||
if (error instanceof ProviderDispatchRequestError) {
|
||||
return writeRuntimeFailure(context, {
|
||||
status: 429,
|
||||
errorCode: "rate_limited",
|
||||
message: error.message,
|
||||
data: toProviderExecutionError(error, error.message).error?.details,
|
||||
});
|
||||
if (context.req.path.startsWith("/v1/"))
|
||||
return writeRuntimeFailure(context, {
|
||||
status: 429,
|
||||
errorCode: "rate_limited",
|
||||
message: error.message,
|
||||
data: toProviderExecutionError(error, error.message).error?.details,
|
||||
});
|
||||
const seconds = optionalRecord(error.details)?.retryAfterSeconds;
|
||||
if (typeof seconds === "number") context.header("Retry-After", String(seconds));
|
||||
return jsonError(context, 429, "rate_limited", error.message);
|
||||
}
|
||||
if (error instanceof SaasError) {
|
||||
this.options.logger?.warn(
|
||||
|
||||
@@ -21,6 +21,126 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("headless runtime", () => {
|
||||
it.each([45, undefined])(
|
||||
"preserves console and runtime denial formats with retry delay %s",
|
||||
async (retryAfterSeconds) => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
runtime = await createConnectorRuntime({
|
||||
...(await fixture()),
|
||||
providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds }) },
|
||||
});
|
||||
const consoleResponse = await request(
|
||||
"/api/connections/github",
|
||||
{ authType: "api_key", values: { apiKey: "fixture-secret" } },
|
||||
"admin-token",
|
||||
"PUT",
|
||||
);
|
||||
const runtimeResponse = await request("/v1/connections/github/connect/api-key", { apiKey: "fixture-secret" });
|
||||
for (const response of [consoleResponse, runtimeResponse]) {
|
||||
expect(response.status).toBe(429);
|
||||
expect(response.headers.get("Retry-After")).toBe(
|
||||
retryAfterSeconds === undefined ? null : String(retryAfterSeconds),
|
||||
);
|
||||
}
|
||||
expect(await consoleResponse.json()).toEqual({
|
||||
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
|
||||
});
|
||||
expect(await runtimeResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
errorCode: "rate_limited",
|
||||
message: "Provider HTTP dispatch is temporarily unavailable.",
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["oauth", "https://host.example/settings/connections"],
|
||||
["credential_validation", "https://host.example/settings/connections"],
|
||||
["oauth", undefined],
|
||||
["credential_validation", undefined],
|
||||
])("completes a denied %s connection request with return URI %s", async (operation, returnUri) => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () =>
|
||||
Response.json({ access_token: "fixture-access-token", token_type: "bearer", scope: "read:user" }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
runtime = await createConnectorRuntime({
|
||||
...(await fixture()),
|
||||
providerHttpDispatch: {
|
||||
beforeAttempt: (attempt) =>
|
||||
attempt.context.operation === operation ? { allow: false, retryAfterSeconds: 45 } : { allow: true },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await request(
|
||||
"/api/oauth/configs/github",
|
||||
{ clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
"admin-token",
|
||||
"PUT",
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
const started = await request("/v1/connections/github/connect", { returnUri });
|
||||
expect(started.status).toBe(200);
|
||||
const attempt = (await started.json()).data;
|
||||
const state = new URL(attempt.authorizationUrl).searchParams.get("state")!;
|
||||
const path = `/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`;
|
||||
const callback = await request(path);
|
||||
if (returnUri) {
|
||||
expect(callback.status).toBe(302);
|
||||
const returned = new URL(callback.headers.get("location")!);
|
||||
expect(`${returned.origin}${returned.pathname}`).toBe(returnUri);
|
||||
expect(Object.fromEntries(returned.searchParams)).toEqual({
|
||||
status: "error",
|
||||
service: "github",
|
||||
code: "rate_limited",
|
||||
message: "Provider HTTP dispatch is temporarily unavailable.",
|
||||
});
|
||||
} else {
|
||||
expect(callback.status).toBe(400);
|
||||
expect(await callback.json()).toEqual({
|
||||
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
|
||||
});
|
||||
}
|
||||
expect(callback.headers.get("Retry-After")).toBeNull();
|
||||
const completed = await request(`/v1/connection-requests/${attempt.connectionRequestId}`);
|
||||
expect((await completed.json()).data).toMatchObject({ status: "failed", errorCode: "rate_limited" });
|
||||
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
|
||||
expect(fetcher).toHaveBeenCalledTimes(operation === "oauth" ? 0 : 1);
|
||||
const replay = await request(path);
|
||||
expect(replay.status).toBe(400);
|
||||
expect(await replay.json()).toMatchObject({ error: { code: "invalid_oauth_state" } });
|
||||
});
|
||||
|
||||
it("preserves dispatch denial for OAuth authorizations without a connection request", async () => {
|
||||
const fetcher = vi.fn<typeof fetch>();
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
runtime = await createConnectorRuntime({
|
||||
...(await fixture()),
|
||||
providerHttpDispatch: { beforeAttempt: () => ({ allow: false, retryAfterSeconds: 45 }) },
|
||||
});
|
||||
await request(
|
||||
"/api/oauth/configs/github",
|
||||
{ clientId: "fixture-client", clientSecret: "fixture-secret" },
|
||||
"admin-token",
|
||||
"PUT",
|
||||
);
|
||||
const started = await request("/api/oauth/authorizations", { service: "github" });
|
||||
expect(started.status).toBe(200);
|
||||
const { state } = await started.json();
|
||||
const callback = await request(`/oauth/callback?state=${encodeURIComponent(state)}&code=fixture-code`);
|
||||
expect(callback.status).toBe(429);
|
||||
expect(callback.headers.get("Retry-After")).toBe("45");
|
||||
expect(await callback.json()).toEqual({
|
||||
error: { code: "rate_limited", message: "Provider HTTP dispatch is temporarily unavailable." },
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect((await (await request("/v1/connections")).json()).data).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves admission denial after a real provider remaps it into validation and transport errors", async () => {
|
||||
let deny = false;
|
||||
const fetcher = vi.fn<typeof fetch>(async () => Response.json({ credit_balance: 100 }));
|
||||
|
||||
Reference in New Issue
Block a user