328 lines
13 KiB
TypeScript
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();
|
|
});
|
|
});
|