Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/telephony/services/AircallConnectionService.server.test.ts
T

328 lines
13 KiB
TypeScript

import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
loadRequiredWebConfig: vi.fn(),
getRlsExecutionContext: vi.fn(),
buildAircallAuthorizationUrl: vi.fn(),
createAircallWebhook: vi.fn(),
deleteAircallWebhook: vi.fn(),
exchangeAircallCodeForToken: vi.fn(),
listAircallUsers: vi.fn(),
pingAircall: vi.fn(),
resolveAircallOAuthConfig: vi.fn(),
reconnectConnection: vi.fn(),
runWithConnectionCredentials: vi.fn(),
tx: {
dataConnection: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
updateMany: vi.fn(),
},
telephonyConnection: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
upsert: vi.fn(),
},
telephonyUserBinding: {
createMany: vi.fn(),
deleteMany: vi.fn(),
findMany: vi.fn(),
},
tenantUser: {
findMany: vi.fn(),
},
},
}));
vi.mock("@hivegrid/config", () => ({ loadRequiredWebConfig: mocks.loadRequiredWebConfig }));
vi.mock("@hivegrid/db/rls-context.server", () => ({ getRlsExecutionContext: mocks.getRlsExecutionContext }));
vi.mock("@hivegrid/integrations", () => ({
AIRCALL_CALL_WEBHOOK_EVENTS: ["call.created", "call.ended"],
buildAircallAuthorizationUrl: mocks.buildAircallAuthorizationUrl,
createAircallWebhook: mocks.createAircallWebhook,
deleteAircallWebhook: mocks.deleteAircallWebhook,
exchangeAircallCodeForToken: mocks.exchangeAircallCodeForToken,
listAircallUsers: mocks.listAircallUsers,
pingAircall: mocks.pingAircall,
resolveAircallOAuthConfig: mocks.resolveAircallOAuthConfig,
}));
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
withTenantRLS: (_tenantId: string, callback: (tx: typeof mocks.tx) => Promise<unknown>) => callback(mocks.tx),
}));
vi.mock("~/utils/integrations/appsEngineService.server", () => ({
reconnectConnection: mocks.reconnectConnection,
runWithConnectionCredentials: mocks.runWithConnectionCredentials,
}));
import { AircallConnectError, buildAircallInstallUrl, completeAircallOAuthCallback } from "./AircallConnectionService.server";
import { signAircallOAuthState, verifyAircallOAuthState } from "./aircallOAuthState.server";
const TENANT_ID = "tenant-1";
const USER_ID = "user-1";
const DATA_CONNECTION_ID = "data-connection-1";
const TELEPHONY_CONNECTION_ID = "telephony-connection-1";
const STATE_SECRET = "aircall-state-secret-for-tests";
beforeEach(() => {
vi.clearAllMocks();
mocks.loadRequiredWebConfig.mockReturnValue({
appName: "HiveGrid",
cryptoSecret: STATE_SECRET,
sessionSecret: "session-secret",
aircall: {
clientId: "aircall-client",
clientSecret: "aircall-client-secret",
redirectUri: "https://app.example.test/integrations/aircall/oauth/callback",
webhookUrl: "https://api.example.test/v1/webhooks/telephony/aircall",
},
});
mocks.resolveAircallOAuthConfig.mockReturnValue({
clientId: "aircall-client",
clientSecret: "aircall-client-secret",
redirectUri: "https://app.example.test/integrations/aircall/oauth/callback",
});
mocks.buildAircallAuthorizationUrl.mockImplementation(
(_config: unknown, state: string) => `https://dashboard.aircall.test/oauth/authorize?state=${encodeURIComponent(state)}`
);
mocks.getRlsExecutionContext.mockReturnValue({ tenantId: TENANT_ID, userId: USER_ID });
mocks.tx.telephonyConnection.findUnique.mockResolvedValue({
id: TELEPHONY_CONNECTION_ID,
dataConnectionId: DATA_CONNECTION_ID,
webhookId: "old-webhook-id",
});
mocks.tx.telephonyConnection.findMany.mockResolvedValue([]);
mocks.tx.dataConnection.findMany.mockResolvedValue([{ id: DATA_CONNECTION_ID, connectionStatus: "active" }]);
mocks.tx.dataConnection.findFirst.mockResolvedValue({ connectionStatus: "active" });
mocks.tx.telephonyConnection.upsert.mockResolvedValue({ id: TELEPHONY_CONNECTION_ID });
mocks.tx.telephonyUserBinding.findMany.mockResolvedValue([{ tenantUserId: "membership-manual", providerUserId: "aircall-manual" }]);
mocks.tx.tenantUser.findMany.mockResolvedValue([
{ id: "membership-auto", user: { email: "Ada@Example.test" } },
{ id: "membership-manual", user: { email: "manual@example.test" } },
]);
mocks.tx.telephonyUserBinding.deleteMany.mockResolvedValue({ count: 0 });
mocks.tx.telephonyUserBinding.createMany.mockResolvedValue({ count: 1 });
mocks.tx.dataConnection.updateMany.mockResolvedValue({ count: 1 });
mocks.exchangeAircallCodeForToken.mockResolvedValue({ accessToken: "new-permanent-access-token" });
mocks.pingAircall.mockResolvedValue(undefined);
mocks.listAircallUsers.mockResolvedValue([
{
id: "aircall-auto",
name: "Ada Agent",
email: "ada@example.test",
available: true,
availabilityStatus: "available",
defaultNumberId: "number-1",
},
{
id: "aircall-manual",
name: "Manual Agent",
email: "manual@example.test",
available: true,
availabilityStatus: "available",
defaultNumberId: null,
},
]);
mocks.createAircallWebhook.mockResolvedValue({
id: "new-webhook-id",
token: "raw-new-webhook-token",
active: true,
events: ["call.created", "call.ended"],
});
mocks.reconnectConnection.mockResolvedValue(undefined);
mocks.deleteAircallWebhook.mockResolvedValue(undefined);
mocks.runWithConnectionCredentials.mockImplementation(async (_input: unknown, callback: (credentials: Record<string, unknown>) => Promise<unknown>) =>
callback({ accessToken: "old-permanent-access-token", webhookToken: "raw-old-webhook-token" })
);
});
describe("Aircall connection lifecycle", () => {
it("reuses the tenant slot and binds it into the signed install state", async () => {
const authorizationUrl = await buildAircallInstallUrl({
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
});
const state = new URL(authorizationUrl).searchParams.get("state");
expect(state).toBeTruthy();
expect(verifyAircallOAuthState(state!, { secret: STATE_SECRET })).toMatchObject({
ok: true,
payload: {
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
connectionId: DATA_CONNECTION_ID,
},
});
expect(mocks.tx.dataConnection.create).not.toHaveBeenCalled();
});
it("creates one pending Apps Engine slot when the tenant has none", async () => {
mocks.tx.telephonyConnection.findUnique.mockResolvedValueOnce(null);
mocks.tx.dataConnection.findMany.mockResolvedValueOnce([]);
mocks.tx.dataConnection.create.mockResolvedValueOnce({ id: DATA_CONNECTION_ID, connectionStatus: "pending" });
mocks.tx.telephonyConnection.create.mockResolvedValueOnce({ id: TELEPHONY_CONNECTION_ID });
await buildAircallInstallUrl({ tenantId: TENANT_ID, tenantSlug: "acme", userId: USER_ID });
expect(mocks.tx.dataConnection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: TENANT_ID,
appId: "aircall",
connectionStatus: "pending",
createdByUserId: USER_ID,
}),
select: { id: true, connectionStatus: true },
});
expect(mocks.tx.telephonyConnection.create).toHaveBeenCalledWith({
data: { tenantId: TENANT_ID, provider: "aircall", dataConnectionId: DATA_CONNECTION_ID },
select: { id: true },
});
});
it("stores secrets only through DataConnection and auto-binds unique email matches", async () => {
const state = signAircallOAuthState({
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
connectionId: DATA_CONNECTION_ID,
});
await expect(completeAircallOAuthCallback({ code: "one-time-code", state })).resolves.toEqual({
tenantId: TENANT_ID,
tenantSlug: "acme",
dataConnectionId: DATA_CONNECTION_ID,
telephonyConnectionId: TELEPHONY_CONNECTION_ID,
providerUserCount: 2,
autoBoundUserCount: 1,
});
expect(mocks.reconnectConnection).toHaveBeenCalledWith({
tenantId: TENANT_ID,
connectionId: DATA_CONNECTION_ID,
expectedAppId: "aircall",
actorUserId: USER_ID,
credentials: {
accessToken: "new-permanent-access-token",
webhookToken: "raw-new-webhook-token",
},
});
expect(mocks.pingAircall).toHaveBeenCalledWith({ accessToken: "new-permanent-access-token" });
expect(mocks.createAircallWebhook).toHaveBeenCalledWith(
{ accessToken: "new-permanent-access-token" },
{
name: "HiveGrid",
url: "https://api.example.test/v1/webhooks/telephony/aircall",
events: ["call.created", "call.ended"],
}
);
expect(mocks.reconnectConnection.mock.calls[0]?.[0].credentials).not.toHaveProperty("refreshToken");
const upsertInput = mocks.tx.telephonyConnection.upsert.mock.calls[0]?.[0];
const expectedHash = createHash("sha256").update("raw-new-webhook-token", "utf8").digest("hex");
expect(upsertInput).toMatchObject({
update: { dataConnectionId: DATA_CONNECTION_ID, webhookId: "new-webhook-id", webhookTokenHash: expectedHash },
});
expect(JSON.stringify(upsertInput)).not.toContain("raw-new-webhook-token");
expect(JSON.stringify(upsertInput)).not.toContain("new-permanent-access-token");
expect(mocks.tx.telephonyUserBinding.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
tenantId: TENANT_ID,
connectionId: TELEPHONY_CONNECTION_ID,
tenantUserId: "membership-auto",
providerUserId: "aircall-auto",
providerEmail: "ada@example.test",
source: "email_auto",
}),
],
skipDuplicates: true,
});
expect(mocks.deleteAircallWebhook).toHaveBeenCalledWith({ accessToken: "old-permanent-access-token" }, "old-webhook-id");
});
it("rejects a callback outside the state-bound RLS identity before exchange", async () => {
mocks.getRlsExecutionContext.mockReturnValue({ tenantId: TENANT_ID, userId: "different-user" });
const state = signAircallOAuthState({
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
connectionId: DATA_CONNECTION_ID,
});
await expect(completeAircallOAuthCallback({ code: "one-time-code", state })).rejects.toEqual(
expect.objectContaining<Partial<AircallConnectError>>({ code: "invalid_state" })
);
expect(mocks.exchangeAircallCodeForToken).not.toHaveBeenCalled();
});
it("deletes a newly-created webhook if encrypted credential persistence fails", async () => {
mocks.tx.dataConnection.findFirst.mockResolvedValue({ connectionStatus: "pending" });
mocks.reconnectConnection.mockRejectedValue(new Error("database unavailable"));
const state = signAircallOAuthState({
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
connectionId: DATA_CONNECTION_ID,
});
await expect(completeAircallOAuthCallback({ code: "one-time-code", state })).rejects.toEqual(
expect.objectContaining<Partial<AircallConnectError>>({ code: "persistence_failed" })
);
expect(mocks.runWithConnectionCredentials).not.toHaveBeenCalled();
expect(mocks.deleteAircallWebhook).toHaveBeenCalledWith({ accessToken: "new-permanent-access-token" }, "new-webhook-id");
});
it("marks a successful reinstall degraded when the previous webhook cannot be removed", async () => {
mocks.deleteAircallWebhook.mockRejectedValueOnce(new Error("provider unavailable"));
const state = signAircallOAuthState({
tenantId: TENANT_ID,
tenantSlug: "acme",
userId: USER_ID,
connectionId: DATA_CONNECTION_ID,
});
await expect(completeAircallOAuthCallback({ code: "one-time-code", state })).rejects.toEqual(
expect.objectContaining<Partial<AircallConnectError>>({ code: "webhook_failed" })
);
expect(mocks.deleteAircallWebhook).toHaveBeenCalledWith({ accessToken: "old-permanent-access-token" }, "old-webhook-id");
expect(mocks.tx.dataConnection.updateMany).toHaveBeenCalledWith({
where: { id: DATA_CONNECTION_ID, tenantId: TENANT_ID, appId: "aircall" },
data: {
connectionStatus: "degraded",
healthMessageKey: "telephony.health.staleWebhookCleanupFailed",
lastHealthCheck: expect.any(Date),
},
});
});
it("stops when legacy data already contains multiple Aircall slots", async () => {
mocks.tx.telephonyConnection.findUnique.mockResolvedValueOnce(null);
mocks.tx.dataConnection.findMany.mockResolvedValueOnce([
{ id: "slot-1", connectionStatus: "active" },
{ id: "slot-2", connectionStatus: "revoked" },
]);
await expect(buildAircallInstallUrl({ tenantId: TENANT_ID, tenantSlug: "acme", userId: USER_ID })).rejects.toEqual(
expect.objectContaining<Partial<AircallConnectError>>({ code: "slot_conflict" })
);
expect(mocks.buildAircallAuthorizationUrl).not.toHaveBeenCalled();
});
it("requires the active HalloPetra provider to be disconnected before Aircall OAuth starts", async () => {
mocks.tx.telephonyConnection.findMany.mockResolvedValueOnce([{ dataConnectionId: "hallopetra-data-1" }]);
mocks.tx.dataConnection.findFirst.mockResolvedValueOnce({ id: "hallopetra-data-1" });
await expect(buildAircallInstallUrl({ tenantId: TENANT_ID, tenantSlug: "acme", userId: USER_ID })).rejects.toEqual(
expect.objectContaining<Partial<AircallConnectError>>({ code: "provider_conflict" }),
);
expect(mocks.buildAircallAuthorizationUrl).not.toHaveBeenCalled();
});
});