Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions packages/connections/src/connected-hook.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";
import {
fireConnectedHook,
fireInferenceCredentialSeedableHook,
type InferenceCredentialSeedableInfo,
type ServiceConnectedInfo,
} from "./connected-hook";

function seedableInfo(): InferenceCredentialSeedableInfo {
Expand All @@ -15,6 +18,46 @@ function seedableInfo(): InferenceCredentialSeedableInfo {
};
}

function connectedInfo(): ServiceConnectedInfo {
return {
tenantId: "tenant_1",
principalId: "principal_1",
connectorId: "github",
displayName: "GitHub",
};
}

describe("fireConnectedHook", () => {
test("does nothing when no hook is wired", async () => {
await expect(
fireConnectedHook(undefined, () => {}, connectedInfo()),
).resolves.toBeUndefined();
});

test("logs and reports a hook failure rather than breaking the connect", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const logged: string[] = [];
await expect(
fireConnectedHook(
() => {
throw new Error("card settle unavailable");
},
(line) => logged.push(line),
connectedInfo(),
),
).resolves.toBeUndefined();
expect(logged).toHaveLength(1);
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "fire_connected_hook",
tenantId: "tenant_1",
extra: { connectorId: "github" },
});
report.mockRestore();
});
});

describe("fireInferenceCredentialSeedableHook", () => {
test("does nothing when no hook is wired", async () => {
await expect(
Expand All @@ -34,7 +77,8 @@ describe("fireInferenceCredentialSeedableHook", () => {
expect(calls).toEqual([seedableInfo()]);
});

test("logs and swallows a hook failure rather than breaking the connect", async () => {
test("logs and reports a hook failure rather than breaking the connect", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const logged: string[] = [];
await expect(
fireInferenceCredentialSeedableHook(
Expand All @@ -48,5 +92,13 @@ describe("fireInferenceCredentialSeedableHook", () => {
expect(logged).toHaveLength(1);
expect(logged[0]).toContain("tenant_1");
expect(logged[0]).toContain("drain unavailable");
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "fire_inference_credential_seedable_hook",
tenantId: "tenant_1",
extra: { provider: "ollama" },
});
report.mockRestore();
});
});
11 changes: 11 additions & 0 deletions packages/connections/src/connected-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// without knowing which door the connection came through. Best-effort
// by contract: a hook failure is logged and never breaks the connect
// itself — the credential is already stored when this fires.
import { reportError } from "@corbits/error-sink";

export type ServiceConnectedInfo = {
readonly tenantId: string;
Expand All @@ -30,6 +31,11 @@ export async function fireConnectedHook(
log(
`onConnected hook failed for ${info.connectorId} on tenant ${info.tenantId}: ${message}`,
);
reportError(cause, {
operation: "fire_connected_hook",
tenantId: info.tenantId,
extra: { connectorId: info.connectorId },
});
}
}

Expand Down Expand Up @@ -77,5 +83,10 @@ export async function fireInferenceCredentialSeedableHook(
log(
`onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`,
);
reportError(cause, {
operation: "fire_inference_credential_seedable_hook",
tenantId: info.tenantId,
extra: { provider: info.provider },
});
}
}
10 changes: 9 additions & 1 deletion packages/connections/src/github-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// surface GitHub's own `error`/`error_description` shape (distinct from
// a non-2xx status — GitHub's token endpoint answers 200 with an `error`
// field on a rejected code).
import { describe, expect, test } from "bun:test";
import { describe, expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";
import {
exchangeCodeForGithubToken,
type ExchangeFetch,
Expand Down Expand Up @@ -101,6 +102,7 @@ describe("exchangeCodeForGithubToken", () => {
});

test("a transport failure is reported honestly", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const fetchImpl: ExchangeFetch = async () => {
throw new Error("getaddrinfo ENOTFOUND");
};
Expand All @@ -118,5 +120,11 @@ describe("exchangeCodeForGithubToken", () => {
expect(result.message).toContain("Could not reach GitHub");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
}
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_github_token",
});
report.mockRestore();
});
});
2 changes: 2 additions & 0 deletions packages/connections/src/github-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// still names `credentialPlugin: "http"`).

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

export const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
export const GITHUB_TOKEN_EXCHANGE_URL =
Expand Down Expand Up @@ -69,6 +70,7 @@ export async function exchangeCodeForGithubToken(
}),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_github_token" });
return {
ok: false,
message:
Expand Down
25 changes: 24 additions & 1 deletion packages/connections/src/gmail-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// entirely against a stubbed fetch — no Google credentials involved.
// Live-key verification against a real Google OAuth app is a deploy
// concern (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET`), not a test one.
import { expect, test } from "bun:test";
import { expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";

import {
exchangeCodeForGoogleToken,
Expand Down Expand Up @@ -89,3 +90,25 @@ test("a Google error response maps to an honest failure that never echoes token
expect(result.message).toContain("invalid_grant");
expect(result.message).not.toContain("secret-1");
});

test("a transport failure is reported and never crashes", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const result = await exchangeCodeForGoogleToken({
code: "auth-code-1",
redirectUri: "https://bench.example.com/callback",
clientId: "client-1",
clientSecret: "secret-1",
fetchImpl: async () => {
throw new Error("getaddrinfo ENOTFOUND");
},
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected failure");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_google_token",
});
report.mockRestore();
});
2 changes: 2 additions & 0 deletions packages/connections/src/gmail-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// authorize URL) for the credential row to keep alongside the secret.

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

export const GOOGLE_AUTHORIZE_URL =
"https://accounts.google.com/o/oauth2/v2/auth";
Expand Down Expand Up @@ -83,6 +84,7 @@ export async function exchangeCodeForGoogleToken(
body: params.toString(),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_google_token" });
const message = cause instanceof Error ? cause.message : String(cause);
return { ok: false, message: `Google token exchange failed: ${message}` };
}
Expand Down
61 changes: 61 additions & 0 deletions packages/connections/src/huggingface-connect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// The exchange's own contract: parse Hugging Face's response at the trust
// boundary without ever putting token material in a failure message, and
// route a transport failure through reportError.
import { describe, expect, spyOn, test } from "bun:test";
import * as errorSink from "@corbits/error-sink";
import {
exchangeCodeForToken,
type ExchangeFetch,
} from "./huggingface-connect";

describe("exchangeCodeForToken", () => {
test("trades the code and verifier for an access token", async () => {
const fetchImpl: ExchangeFetch = async () =>
new Response(
JSON.stringify({ access_token: "hf_minted_token", expires_in: 3600 }),
{ status: 200 },
);

const result = await exchangeCodeForToken({
code: "auth_code_1",
codeVerifier: "verifier_1",
redirectUri: "https://hub.example.test/callback",
clientId: "client_1",
fetchImpl,
now: () => 0,
});

expect(result).toEqual({
ok: true,
accessToken: "hf_minted_token",
expiresAt: new Date(3600 * 1000).toISOString(),
});
});

test("a transport failure is reported, never token material", async () => {
const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test");
const fetchImpl: ExchangeFetch = async () => {
throw new Error("getaddrinfo ENOTFOUND");
};

const result = await exchangeCodeForToken({
code: "auth_code_1",
codeVerifier: "verifier_1",
redirectUri: "https://hub.example.test/callback",
clientId: "client_1",
fetchImpl,
});

expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain("Could not reach Hugging Face");
expect(result.message).toContain("getaddrinfo ENOTFOUND");
}
expect(report).toHaveBeenCalledTimes(1);
expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error);
expect(report.mock.calls[0]?.[1]).toMatchObject({
operation: "exchange_code_for_huggingface_token",
});
report.mockRestore();
});
});
2 changes: 2 additions & 0 deletions packages/connections/src/huggingface-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// later expiry sweep reads.

import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

export const HUGGINGFACE_AUTHORIZE_URL =
"https://huggingface.co/oauth/authorize";
Expand Down Expand Up @@ -90,6 +91,7 @@ export async function exchangeCodeForToken(
body: body.toString(),
});
} catch (cause) {
reportError(cause, { operation: "exchange_code_for_huggingface_token" });
return {
ok: false,
message:
Expand Down
Loading
Loading