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
5 changes: 4 additions & 1 deletion workers/api/src/lib/connector-grants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { HttpError } from "./auth.js";
import type { Env } from "../types.js";

export type ConnectorProvider = "google_drive" | "zoho_workdrive";
// Widened to `string` (#86): connectors now cover resource types beyond drive/workdrive
// (repos, spreadsheets, …). The known ingest providers stay documented for reference.
// Known values: "google_drive" | "zoho_workdrive" | (any connector id with grantModel:"instance-resource").
export type ConnectorProvider = string;

export interface ConnectorGrant {
id: string;
Expand Down
94 changes: 94 additions & 0 deletions workers/api/src/lib/connectors/behaviour-identity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Proves the #86 refactor is BEHAVIOUR-IDENTICAL: the github + meta tools now obtain
// auth via ctx.connectorClient(...) instead of importing token fns directly, but the
// SAME token source is used (installationTokenForOwner for github, META_ACCESS_TOKEN for
// meta), the SAME requests are made, and the SAME outputs come back.
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Env } from "../../types.js";

vi.mock("../github-app.js", () => ({
githubAppConfigured: () => true,
installationTokenForOwner: vi.fn(),
}));

import { runRegistryTool } from "../tool-registry.js";
import { installationTokenForOwner } from "../github-app.js";

/** Env whose consent lookup returns write-consent for every (instance,connector). */
function envWithConsent(extra: Partial<Env> = {}): Env {
return {
DB: { prepare: () => ({ bind: () => ({ first: async () => ({ ok: 1 }) }) }) },
...extra,
} as unknown as Env;
}

afterEach(() => vi.restoreAllMocks());

describe("github tools stay behaviour-identical through connectorClient", () => {
it("github_workflow_runs mints the owner's installation token and returns the same shape", async () => {
vi.mocked(installationTokenForOwner).mockResolvedValue("gh-installation-token");
const capturedAuth: string[] = [];
vi.spyOn(globalThis, "fetch").mockImplementation(async (_url, init) => {
capturedAuth.push(new Headers(init?.headers).get("Authorization") ?? "");
return new Response(JSON.stringify({ workflow_runs: [{ status: "completed", conclusion: "success", head_branch: "main" }] }), { status: 200 });
});

const r = await runRegistryTool(
"github_workflow_runs",
{ env: envWithConsent(), userId: "u1", instanceId: "i1" },
{ repo: "acme/widgets" },
);
expect(r.success).toBe(true);
// Token minted for the repo owner — same as the old inline installationTokenForOwner(env, userId, owner).
expect(installationTokenForOwner).toHaveBeenCalledWith(expect.anything(), "u1", "acme");
// The GitHub REST calls use the classic `token <t>` header the tool already built.
expect(capturedAuth[0]).toBe("token gh-installation-token");
expect(JSON.parse(r.content)[0]).toMatchObject({ status: "completed", conclusion: "success", branch: "main" });
});

it("github_create_issue (write) still resolves the owner's token after the consent gate", async () => {
vi.mocked(installationTokenForOwner).mockResolvedValue("gh-tok");
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ number: 7, html_url: "https://github.com/acme/widgets/issues/7" }), { status: 201 }),
);
const r = await runRegistryTool(
"github_create_issue",
{ env: envWithConsent(), userId: "u1", instanceId: "i1" },
{ repo: "acme/widgets", title: "hi" },
);
expect(r.success).toBe(true);
expect(r.content).toContain("Opened issue #7");
expect(installationTokenForOwner).toHaveBeenCalledWith(expect.anything(), "u1", "acme");
});
});

describe("meta tools stay behaviour-identical through connectorClient", () => {
it("whatsapp_send_message uses the platform META_ACCESS_TOKEN as the Bearer, same request", async () => {
let auth = "";
let url = "";
vi.spyOn(globalThis, "fetch").mockImplementation(async (u, init) => {
url = String(u);
auth = new Headers(init?.headers).get("Authorization") ?? "";
return new Response(JSON.stringify({ messages: [{ id: "wamid.1" }] }), { status: 200 });
});
const env = envWithConsent({ META_ACCESS_TOKEN: "meta-business-token", WHATSAPP_PHONE_NUMBER_ID: "phone-1" });
const r = await runRegistryTool(
"whatsapp_send_message",
{ env, userId: "u1", instanceId: "i1" },
{ to: "+14155552671", text: "hello" },
);
expect(r.success).toBe(true);
expect(auth).toBe("Bearer meta-business-token"); // same token source as the old ctx.env.META_ACCESS_TOKEN
expect(url).toContain("/phone-1/messages");
});

it("meta tool with the env token unset → same 'not configured' result (no throw)", async () => {
const env = envWithConsent({ WHATSAPP_PHONE_NUMBER_ID: "phone-1" }); // no META_ACCESS_TOKEN
const r = await runRegistryTool(
"whatsapp_send_message",
{ env, userId: "u1", instanceId: "i1" },
{ to: "+14155552671", text: "hello" },
);
expect(r.success).toBe(false);
expect(r.content).toMatch(/not configured/);
});
});
155 changes: 155 additions & 0 deletions workers/api/src/lib/connectors/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Env } from "../../types.js";
import type { Connector } from "./registry.js";

// Mock the collaborators so we can assert connectorClient's dispatch + enforcement in
// isolation from real GitHub/OAuth/DB. Each auth type routes to a distinct collaborator.
vi.mock("./registry.js", () => ({ getConnector: (id: string) => FIXTURES[id] }));
vi.mock("../github-app.js", () => ({ installationTokenForOwner: vi.fn() }));
vi.mock("../connector-oauth.js", () => ({ readConnectorRefreshToken: vi.fn() }));
vi.mock("../connector-grants.js", () => ({ requireConnectorGrant: vi.fn() }));

import { connectorClient } from "./client.js";
import { installationTokenForOwner } from "../github-app.js";
import { readConnectorRefreshToken } from "../connector-oauth.js";
import { requireConnectorGrant } from "../connector-grants.js";

const FIXTURES: Record<string, Connector> = {
app_conn: { id: "app_conn", label: "App Conn", auth: "app", scopes: { read: true, write: true }, grantModel: "user", tools: [] },
oauth_conn: { id: "google_drive", label: "Drive", auth: "oauth", scopes: { read: true, write: false }, grantModel: "user", tools: [] },
env_token_conn: { id: "env_token_conn", label: "Env Token", auth: "token", scopes: { read: false, write: true }, grantModel: "user", tokenEnv: "META_ACCESS_TOKEN", tools: [] },
user_token_conn: { id: "user_token_conn", label: "User Token", auth: "token", scopes: { read: true, write: false }, grantModel: "user", tools: [] },
none_conn: { id: "none_conn", label: "None", auth: "none", scopes: { read: true, write: true }, grantModel: "user", tools: [] },
readonly_conn: { id: "readonly_conn", label: "Read Only", auth: "app", scopes: { read: true, write: false }, grantModel: "user", tools: [] },
granted_conn: { id: "granted_conn", label: "Granted", auth: "app", scopes: { read: true, write: true }, grantModel: "instance-resource", tools: [] },
};
// Point the oauth fixture at the real Drive endpoint key used by client.ts.
FIXTURES.google_drive = FIXTURES.oauth_conn;

const caller = { userId: "u1", instanceId: "i1" };

afterEach(() => vi.clearAllMocks());

describe("connectorClient token dispatch", () => {
it("app auth → installationTokenForOwner, scoped to the resource owner", async () => {
vi.mocked(installationTokenForOwner).mockResolvedValue("gh-token");
const c = connectorClient({} as Env, "app_conn", caller);
const t = await c.token({ resourceId: "acme/widgets" });
expect(t).toBe("gh-token");
expect(installationTokenForOwner).toHaveBeenCalledWith({}, "u1", "acme");
});

it("app auth → throws when no installation token", async () => {
vi.mocked(installationTokenForOwner).mockResolvedValue(null);
const c = connectorClient({} as Env, "app_conn", caller);
await expect(c.token({ resourceId: "acme/widgets" })).rejects.toThrow(/No app_conn access/);
});

it("oauth auth → reads the refresh token then mints an access token from the provider endpoint", async () => {
vi.mocked(readConnectorRefreshToken).mockResolvedValue("refresh-xyz");
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ access_token: "access-abc" }), { status: 200 }),
);
const env = { GOOGLE_CLIENT_ID: "cid", GOOGLE_CLIENT_SECRET: "sec" } as Env;
const c = connectorClient(env, "google_drive", caller);
const t = await c.token();
expect(t).toBe("access-abc");
expect(readConnectorRefreshToken).toHaveBeenCalledWith(env, "u1", "google_drive", "Drive");
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toBe("https://oauth2.googleapis.com/token");
expect(String((init as RequestInit).body)).toContain("refresh_token=refresh-xyz");
fetchSpy.mockRestore();
});

it("token auth (platform env) → returns the env token", async () => {
const env = { META_ACCESS_TOKEN: " meta-tok " } as Env;
const c = connectorClient(env, "env_token_conn", caller);
expect(await c.token()).toBe("meta-tok");
});

it("token auth (platform env) → throws a clear error when the env var is unset", async () => {
const c = connectorClient({} as Env, "env_token_conn", caller);
await expect(c.token()).rejects.toThrow(/not configured/);
});

it("token auth (no env) → falls back to the user's stored key", async () => {
vi.mocked(readConnectorRefreshToken).mockResolvedValue("stored-key");
const c = connectorClient({} as Env, "user_token_conn", caller);
expect(await c.token()).toBe("stored-key");
expect(readConnectorRefreshToken).toHaveBeenCalledWith({}, "u1", "user_token_conn", "User Token");
});

it("none auth → empty token, no collaborators called", async () => {
const c = connectorClient({} as Env, "none_conn", caller);
expect(await c.token()).toBe("");
expect(installationTokenForOwner).not.toHaveBeenCalled();
expect(readConnectorRefreshToken).not.toHaveBeenCalled();
});

it("unknown connector → throws", () => {
expect(() => connectorClient({} as Env, "nope", caller)).toThrow(/Unknown connector/);
});
});

describe("connectorClient scope enforcement", () => {
it("read-only connector rejects a write-scoped token request", async () => {
const c = connectorClient({} as Env, "readonly_conn", caller);
await expect(c.token({ scope: "write", resourceId: "acme/x" })).rejects.toThrow(/read-only/);
expect(installationTokenForOwner).not.toHaveBeenCalled();
});

it("read-only connector still serves a read-scoped request", async () => {
vi.mocked(installationTokenForOwner).mockResolvedValue("ro-token");
const c = connectorClient({} as Env, "readonly_conn", caller);
expect(await c.token({ scope: "read", resourceId: "acme/x" })).toBe("ro-token");
});
});

describe("connectorClient grant enforcement (fail-closed)", () => {
it("instance-resource connector mints a token only after requireConnectorGrant passes", async () => {
vi.mocked(requireConnectorGrant).mockResolvedValue({} as never);
vi.mocked(installationTokenForOwner).mockResolvedValue("granted-token");
const c = connectorClient({} as Env, "granted_conn", caller);
const t = await c.token({ resourceId: "res-1" });
expect(t).toBe("granted-token");
expect(requireConnectorGrant).toHaveBeenCalledWith({}, "i1", "u1", "granted_conn", "res-1");
});

it("instance-resource connector → 403 when the grant is missing (fail-closed, no token minted)", async () => {
vi.mocked(requireConnectorGrant).mockRejectedValue(Object.assign(new Error("Connector grant does not allow this agent to access that resource"), { status: 403 }));
const c = connectorClient({} as Env, "granted_conn", caller);
await expect(c.token({ resourceId: "res-x" })).rejects.toThrow(/grant does not allow/);
expect(installationTokenForOwner).not.toHaveBeenCalled();
});

it("requireGrant() → 403 when there is no instance context (fail-closed)", async () => {
const c = connectorClient({} as Env, "granted_conn", { userId: "u1" });
await expect(c.requireGrant("res-1")).rejects.toThrow(/not granted|No instance/);
});

it("requireGrant() on a non-resource connector is a misuse → errors", async () => {
const c = connectorClient({} as Env, "app_conn", caller);
await expect(c.requireGrant("res-1")).rejects.toThrow(/not resource-granted/);
});
});

describe("connectorClient.fetch", () => {
it("attaches the minted token as a Bearer header", async () => {
const env = { META_ACCESS_TOKEN: "meta-tok" } as Env;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 }));
const c = connectorClient(env, "env_token_conn", caller);
await c.fetch("https://graph.example/messages", { method: "POST" });
const [, init] = fetchSpy.mock.calls[0];
expect(new Headers((init as RequestInit).headers).get("Authorization")).toBe("Bearer meta-tok");
fetchSpy.mockRestore();
});

it("attaches no Authorization header for a none-auth connector", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("ok", { status: 200 }));
const c = connectorClient({} as Env, "none_conn", caller);
await c.fetch("https://relay.example/ping");
const [, init] = fetchSpy.mock.calls[0];
expect(new Headers((init as RequestInit).headers).has("Authorization")).toBe(false);
fetchSpy.mockRestore();
});
});
Loading
Loading