145 lines
4.8 KiB
TypeScript
145 lines
4.8 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
events: [] as string[],
|
|
updateMany: vi.fn(async () => ({ count: 1 })),
|
|
encryptTenantField: vi.fn(async () => "sealed-credentials"),
|
|
createAuditEvent: vi.fn(async () => undefined),
|
|
validateConnection: vi.fn(async () => ({
|
|
ok: true,
|
|
health: "active" as const,
|
|
messageKey: "integrations.health.active",
|
|
})),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: async (_tenantId: string, callback: (tx: unknown) => Promise<unknown>) => {
|
|
mocks.events.push("transaction");
|
|
return callback({ dataConnection: { updateMany: mocks.updateMany } });
|
|
},
|
|
}));
|
|
vi.mock("@hivegrid/db/crypto/tenant-envelope.server", () => ({
|
|
decryptTenantField: vi.fn(),
|
|
encryptTenantField: async (...args: unknown[]) => {
|
|
mocks.events.push("encrypt");
|
|
return mocks.encryptTenantField(...args);
|
|
},
|
|
}));
|
|
vi.mock("@hivegrid/db/audit-events.server", () => ({ createAuditEvent: mocks.createAuditEvent }));
|
|
vi.mock("@hivegrid/db/notifications.server", () => ({ createNotificationDelivery: vi.fn() }));
|
|
vi.mock("~/utils/db.server", () => ({ db: {} }));
|
|
vi.mock("@hivegrid/integrations", () => ({
|
|
resolveAdapter: vi.fn(() => ({
|
|
key: "kwp",
|
|
connectionType: "oauth2",
|
|
validateConnection: async (...args: unknown[]) => {
|
|
mocks.events.push("validate");
|
|
return mocks.validateConnection(...args);
|
|
},
|
|
})),
|
|
}));
|
|
|
|
import { activateOAuthConnection, AppsEngineServiceError } from "./appsEngineService.server";
|
|
|
|
describe("activateOAuthConnection", () => {
|
|
beforeEach(() => {
|
|
mocks.events.length = 0;
|
|
mocks.updateMany.mockClear();
|
|
mocks.encryptTenantField.mockClear();
|
|
mocks.createAuditEvent.mockClear();
|
|
mocks.validateConnection.mockClear();
|
|
mocks.validateConnection.mockResolvedValue({
|
|
ok: true,
|
|
health: "active",
|
|
messageKey: "integrations.health.active",
|
|
});
|
|
mocks.updateMany.mockResolvedValue({ count: 1 });
|
|
});
|
|
|
|
it("validates outside the transaction and persists encrypted OAuth credentials with expiry", async () => {
|
|
const tokenExpiresAt = new Date("2026-07-20T18:30:00.000Z");
|
|
await activateOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
connectionId: "connection-1",
|
|
appId: "kwp",
|
|
credentials: { accessToken: "access", refreshToken: "refresh" },
|
|
tokenExpiresAt,
|
|
actorUserId: "user-1",
|
|
expectedUpdatedAt: new Date("2026-07-20T12:00:00.000Z"),
|
|
});
|
|
|
|
expect(mocks.events).toEqual(["validate", "encrypt", "transaction"]);
|
|
expect(mocks.encryptTenantField).toHaveBeenCalledWith(
|
|
"tenant-1",
|
|
JSON.stringify({ accessToken: "access", refreshToken: "refresh" }),
|
|
);
|
|
expect(mocks.updateMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "connection-1",
|
|
tenantId: "tenant-1",
|
|
appId: "kwp",
|
|
archivedAt: null,
|
|
updatedAt: new Date("2026-07-20T12:00:00.000Z"),
|
|
},
|
|
data: expect.objectContaining({
|
|
credentialsCiphertext: "sealed-credentials",
|
|
tokenExpiresAt,
|
|
connectionStatus: "active",
|
|
healthMessageKey: "integrations.health.active",
|
|
}),
|
|
});
|
|
expect(mocks.createAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tenantId: "tenant-1",
|
|
actorId: "user-1",
|
|
action: "INTEGRATION_OAUTH_CONNECT",
|
|
targetId: "connection-1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("does not persist credentials when the provider validation fails", async () => {
|
|
mocks.validateConnection.mockResolvedValueOnce({
|
|
ok: false,
|
|
health: "broken",
|
|
messageKey: "integrations.health.scopeMissing",
|
|
});
|
|
|
|
await expect(
|
|
activateOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
connectionId: "connection-1",
|
|
appId: "kwp",
|
|
credentials: {},
|
|
tokenExpiresAt: null,
|
|
actorUserId: "user-1",
|
|
expectedUpdatedAt: new Date("2026-07-20T12:00:00.000Z"),
|
|
}),
|
|
).rejects.toEqual(
|
|
expect.objectContaining<Partial<AppsEngineServiceError>>({
|
|
messageKey: "integrations.health.scopeMissing",
|
|
}),
|
|
);
|
|
expect(mocks.events).toEqual(["validate"]);
|
|
expect(mocks.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects a slot that is not owned by the tenant and app", async () => {
|
|
mocks.updateMany.mockResolvedValueOnce({ count: 0 });
|
|
|
|
await expect(
|
|
activateOAuthConnection({
|
|
tenantId: "tenant-1",
|
|
connectionId: "foreign-slot",
|
|
appId: "kwp",
|
|
credentials: { accessToken: "access" },
|
|
tokenExpiresAt: null,
|
|
actorUserId: "user-1",
|
|
expectedUpdatedAt: new Date("2026-07-20T12:00:00.000Z"),
|
|
}),
|
|
).rejects.toMatchObject({ status: 404 });
|
|
expect(mocks.createAuditEvent).not.toHaveBeenCalled();
|
|
});
|
|
});
|