mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Slack catalog discovery now checks live task, user, endpoint and credentials without calculating an unused compatibility revision. Full authority callers retain the unchanged revision and search-token path. Validation: 44 affected cases, full repository typecheck/build, current-head full CI/E2E and security checks, Greptile Base 5/5 with no unresolved findings. Identical production catalog output with revision statements reduced from two to zero in real-PostgreSQL comparisons. Hosted latency acceptance remains pending. Co-Authored-By: Paperclip <noreply@paperclip.ing>
198 lines
12 KiB
TypeScript
198 lines
12 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
import {
|
|
agents, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, companies,
|
|
companyMemberships, createDb, heartbeatRuns, issues, toolApplications, toolConnections,
|
|
} from "@paperclipai/db";
|
|
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
|
import { createChatCredentials } from "../services/chat-channels/credentials.js";
|
|
import { createSlackTaskAuthorityRegistration } from "../services/chat-channels/slack/task-authority.js";
|
|
import { resolveSlackTaskAuthority } from "../services/connectors/slack-authority.js";
|
|
import { slackAssignedResource } from "../services/connectors/slack.js";
|
|
import { slackToolsForSession, syncSlackBotTools } from "../services/connectors/slack-catalog.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { initializeRunIdentity } from "../services/run-identity.js";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const describeDatabase = support.supported ? describe : describe.skip;
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>(yes => { resolve = yes; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describeDatabase("Slack tool preparation", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
|
|
beforeAll(async () => {
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-slack-preparation-");
|
|
db = createDb(database.connectionString, { maxConnections: 1 });
|
|
await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true });
|
|
});
|
|
afterAll(async () => { await database?.cleanup(); });
|
|
|
|
async function fixture() {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), userId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Slack preparation", issuePrefix: randomUUID().slice(0, 8).toUpperCase() });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Slack agent" });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "owner" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Briefing", assigneeAgentId: agentId, responsibleUserId: userId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId: userId, contextSnapshot: { issueId, source: "routine" } });
|
|
await initializeRunIdentity(db, { companyId, agentId, issueId, runId, responsibleUserId: userId, cause: "routine" });
|
|
const refs: ToolCredentialSecretRef[] = [];
|
|
const endpoints: Array<typeof chatEndpoints.$inferSelect> = [];
|
|
const links: Array<typeof chatIdentityLinks.$inferSelect> = [];
|
|
for (let i = 0; i < 2; i++) {
|
|
const [application] = await db.insert(toolApplications).values({ companyId, name: `Slack ${i}`, type: "mcp_http" }).returning();
|
|
const credentialSecretRefs = ["botToken", "signingSecret", "appToken"].map(key => ({ secretId: randomUUID(), configPath: `credentials.${key}`, versionSelector: "latest" as const, required: true }));
|
|
refs.push(...credentialSecretRefs);
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: application.id, name: `Slack ${i}`, uid: randomUUID(), transport: "mcp_remote", credentialSecretRefs }).returning();
|
|
const [endpoint] = await db.insert(chatEndpoints).values({ companyId, connectionId: connection.id, assignedAgentId: agentId, provider: "slack", publicId: randomUUID(), providerAccountId: `T${i}`, status: "active" }).returning();
|
|
endpoints.push(endpoint);
|
|
const [principal] = await db.insert(chatExternalPrincipals).values({ companyId, provider: "slack", providerAccountId: `T${i}`, externalId: `U${i}`, isBot: false }).returning();
|
|
const [link] = await db.insert(chatIdentityLinks).values({ companyId, endpointId: endpoint.id, principalId: principal.id, paperclipUserId: userId, status: "linked" }).returning();
|
|
links.push(link);
|
|
await db.transaction(tx => syncSlackBotTools(tx, endpoint, userId, true));
|
|
}
|
|
const token = "current-bot-token";
|
|
const resolveSecretValue = vi.fn(async (_companyId: string, secretId: string) => {
|
|
const ref = refs.find(ref => ref.secretId === secretId)!;
|
|
return ref.configPath === "credentials.botToken" ? token : "webhook-secret";
|
|
});
|
|
const credentials = createChatCredentials(db, { secrets: { resolveSecretValue } } as unknown as Parameters<typeof createChatCredentials>[1]);
|
|
const registration = createSlackTaskAuthorityRegistration(db, {
|
|
resolveCredentials: credentials.resolveCredentials,
|
|
authorizeInboundWakeup: async () => { throw new Error("This is an ordinary task"); },
|
|
});
|
|
return { binding: { companyId, agentId, issueId, runId }, endpoints, links, refs, resolveSecretValue,
|
|
...registration };
|
|
}
|
|
|
|
const paths = {
|
|
assignments: async (binding: { companyId: string; agentId: string; runId: string; issueId: string }) =>
|
|
(await slackAssignedResource(db, binding)).map(resource => resource.id),
|
|
tools: async (binding: { companyId: string; agentId: string; runId: string; issueId: string }) =>
|
|
[...new Set((await slackToolsForSession(db, binding)).map(tool => String(tool.providerMetadata?.endpointId)))],
|
|
};
|
|
|
|
it("does not compute an unused revision when preparing the session catalog", async () => {
|
|
const f = await fixture();
|
|
const queries: string[] = [];
|
|
const measured = drizzle(db.$client, { logger: { logQuery: query => queries.push(query) } }) as typeof db;
|
|
const registration = createSlackTaskAuthorityRegistration(measured, {
|
|
resolveCredentials: createChatCredentials(measured, { secrets: { resolveSecretValue: f.resolveSecretValue } } as unknown as Parameters<typeof createChatCredentials>[1]).resolveCredentials,
|
|
authorizeInboundWakeup: async () => { throw new Error("This is an ordinary task"); },
|
|
});
|
|
try {
|
|
const tools = await slackToolsForSession(measured, f.binding);
|
|
expect([...new Set(tools.map(tool => tool.providerMetadata?.endpointId))].sort()).toEqual(f.endpoints.map(endpoint => endpoint.id).sort());
|
|
expect(queries.filter(query => query.includes("row_to_json(revision_row)"))).toHaveLength(0);
|
|
queries.length = 0;
|
|
const authority = await resolveSlackTaskAuthority(measured, { ...f.binding, endpointId: f.endpoints[1].id });
|
|
expect(authority.revision).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(queries.filter(query => query.includes("row_to_json(revision_row)"))).toHaveLength(1);
|
|
await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.id, f.links[0].id));
|
|
queries.length = 0;
|
|
f.resolveSecretValue.mockClear();
|
|
expect([...new Set((await slackToolsForSession(measured, f.binding)).map(tool => tool.providerMetadata?.endpointId))]).toEqual([f.endpoints[1].id]);
|
|
expect(f.resolveSecretValue).toHaveBeenCalledTimes(1);
|
|
expect(queries.filter(query => query.includes("row_to_json(revision_row)"))).toHaveLength(0);
|
|
} finally {
|
|
registration.unregisterSlackTaskAuthority();
|
|
f.unregisterSlackTaskAuthority();
|
|
}
|
|
});
|
|
|
|
it.each(Object.entries(paths))("overlaps two independent endpoint checks for %s", async (_name, prepare) => {
|
|
const f = await fixture();
|
|
const gate = deferred();
|
|
let active = 0, peak = 0;
|
|
f.resolveSecretValue.mockImplementation(async (_companyId, secretId) => {
|
|
if (f.refs.find(ref => ref.secretId === secretId)!.configPath === "credentials.botToken") {
|
|
active++; peak = Math.max(peak, active);
|
|
await gate.promise;
|
|
active--;
|
|
}
|
|
return "credential";
|
|
});
|
|
const pending = prepare(f.binding);
|
|
try {
|
|
await vi.waitFor(() => expect(peak).toBe(2), { timeout: 1000, interval: 10 });
|
|
} finally {
|
|
gate.resolve();
|
|
await pending;
|
|
f.unregisterSlackTaskAuthority();
|
|
}
|
|
expect((await pending).sort()).toEqual(f.endpoints.map(endpoint => endpoint.id).sort());
|
|
});
|
|
|
|
it("resolves only current bot credentials and observes rotation without a cache", async () => {
|
|
const f = await fixture();
|
|
f.resolveSecretValue.mockImplementation(async (_companyId, secretId) => {
|
|
const ref = f.refs.find(ref => ref.secretId === secretId)!;
|
|
if (ref.configPath !== "credentials.botToken") throw new Error("Webhook credential is unavailable");
|
|
return f.resolveSecretValue.mock.calls.length === 1 ? "old-bot-token" : "rotated-bot-token";
|
|
});
|
|
try {
|
|
const binding = { ...f.binding, endpointId: f.endpoints[0].id };
|
|
const before = await resolveSlackTaskAuthority(db, binding);
|
|
const after = await resolveSlackTaskAuthority(db, binding);
|
|
expect(before.botToken).toBe("old-bot-token");
|
|
expect(after.botToken).toBe("rotated-bot-token");
|
|
expect(after.revision).not.toBe(before.revision);
|
|
expect(f.resolveSecretValue).toHaveBeenCalledTimes(2);
|
|
} finally { f.unregisterSlackTaskAuthority(); }
|
|
});
|
|
|
|
it("preserves a failed or missing bot credential", async () => {
|
|
const f = await fixture();
|
|
const binding = { ...f.binding, endpointId: f.endpoints[0].id };
|
|
try {
|
|
const error = new Error("Bot credential is revoked");
|
|
f.resolveSecretValue.mockRejectedValueOnce(error);
|
|
await expect(resolveSlackTaskAuthority(db, binding)).rejects.toBe(error);
|
|
f.resolveSecretValue.mockResolvedValueOnce("");
|
|
await expect(resolveSlackTaskAuthority(db, binding)).rejects.toMatchObject({ status: 403 });
|
|
} finally { f.unregisterSlackTaskAuthority(); }
|
|
});
|
|
|
|
it.each(Object.entries(paths))("rechecks bot credential availability for %s", async (_name, prepare) => {
|
|
const f = await fixture();
|
|
try {
|
|
expect((await prepare(f.binding)).sort()).toEqual(f.endpoints.map(endpoint => endpoint.id).sort());
|
|
const error = new Error("Bot credential is revoked");
|
|
f.resolveSecretValue.mockClear();
|
|
f.resolveSecretValue.mockRejectedValue(error);
|
|
await expect(prepare(f.binding)).rejects.toBe(error);
|
|
expect(f.resolveSecretValue).toHaveBeenCalledTimes(2);
|
|
f.resolveSecretValue.mockClear();
|
|
f.resolveSecretValue.mockResolvedValue("");
|
|
expect(await prepare(f.binding)).toEqual([]);
|
|
expect(f.resolveSecretValue).toHaveBeenCalledTimes(2);
|
|
} finally { f.unregisterSlackTaskAuthority(); }
|
|
});
|
|
|
|
it.each(Object.entries(paths))("rechecks identity revocation for %s without touching another company", async (_name, prepare) => {
|
|
const f = await fixture();
|
|
const foreign = await fixture();
|
|
foreign.unregisterSlackTaskAuthority();
|
|
const registration = createSlackTaskAuthorityRegistration(db, {
|
|
resolveCredentials: createChatCredentials(db, { secrets: { resolveSecretValue: f.resolveSecretValue } } as unknown as Parameters<typeof createChatCredentials>[1]).resolveCredentials,
|
|
authorizeInboundWakeup: async () => { throw new Error("This is an ordinary task"); },
|
|
});
|
|
try {
|
|
expect((await prepare(f.binding)).sort()).toEqual(f.endpoints.map(endpoint => endpoint.id).sort());
|
|
await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.id, f.links[0].id));
|
|
f.resolveSecretValue.mockClear();
|
|
expect(await prepare(f.binding)).toEqual([f.endpoints[1].id]);
|
|
expect(f.resolveSecretValue).toHaveBeenCalledTimes(1);
|
|
expect(f.resolveSecretValue.mock.calls[0][0]).toBe(f.binding.companyId);
|
|
} finally { registration.unregisterSlackTaskAuthority(); }
|
|
});
|
|
});
|