forked from EduCraft/curriculum-project-hub
feat: archive bindings on Feishu lifecycle events
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { archiveFeishuBindingForLifecycleEvent } from "../../src/feishu/bindingLifecycle.js";
|
||||
import { scopedFeishuPrincipalId } from "../../src/feishu/identityNamespace.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedProject } from "./helpers.js";
|
||||
|
||||
const CONNECTION_ID = "feishu-connection-lifecycle";
|
||||
|
||||
describe("Feishu binding lifecycle events", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => prisma.$disconnect());
|
||||
|
||||
it("archives a bound project and revokes its chat grant when the chat is dissolved", async () => {
|
||||
await seedProject("project-dissolved", "chat-dissolved");
|
||||
await seedFeishuConnection();
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "project-dissolved",
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"),
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "project-dissolved",
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: "chat-dissolved",
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "project-dissolved",
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"),
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-dissolved",
|
||||
eventId: "event-dissolved",
|
||||
reason: "chat_dissolved",
|
||||
})).resolves.toEqual({ archived: true, projectId: "project-dissolved" });
|
||||
|
||||
await expect(prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId: "chat-dissolved" },
|
||||
select: { archivedAt: true },
|
||||
})).resolves.toMatchObject({ archivedAt: expect.any(Date) });
|
||||
await expect(prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceId: "project-dissolved",
|
||||
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-dissolved"),
|
||||
},
|
||||
select: { revokedAt: true },
|
||||
})).resolves.toMatchObject({ revokedAt: expect.any(Date) });
|
||||
await expect(prisma.permissionGrant.findFirst({
|
||||
where: { resourceId: "project-dissolved", principalId: "chat-dissolved" },
|
||||
select: { revokedAt: true },
|
||||
})).resolves.toMatchObject({ revokedAt: expect.any(Date) });
|
||||
await expect(prisma.permissionGrant.findFirst({
|
||||
where: {
|
||||
resourceId: "project-dissolved",
|
||||
principalId: scopedFeishuPrincipalId("CHAT", CONNECTION_ID, "chat-unrelated"),
|
||||
},
|
||||
select: { revokedAt: true },
|
||||
})).resolves.toEqual({ revokedAt: null });
|
||||
await expect(prisma.auditEntry.findFirst({
|
||||
where: { projectId: "project-dissolved", action: "project.chat_binding_archived" },
|
||||
select: { organizationId: true, metadata: true },
|
||||
})).resolves.toEqual({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
metadata: { chatId: "chat-dissolved", eventId: "event-dissolved", reason: "chat_dissolved" },
|
||||
});
|
||||
});
|
||||
|
||||
it("is idempotent when Feishu redelivers a lifecycle event", async () => {
|
||||
await seedProject("project-redelivery", "chat-redelivery");
|
||||
await seedFeishuConnection();
|
||||
|
||||
await archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-redelivery",
|
||||
eventId: "event-redelivery",
|
||||
reason: "bot_removed",
|
||||
});
|
||||
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-redelivery",
|
||||
eventId: "event-redelivery",
|
||||
reason: "bot_removed",
|
||||
})).resolves.toEqual({ archived: false });
|
||||
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { projectId: "project-redelivery", action: "project.chat_binding_archived" },
|
||||
})).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("does not archive a later binding when an old lifecycle event is redelivered", async () => {
|
||||
await seedProject("project-original", "chat-rebound");
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: "project-rebound",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: "Rebound project",
|
||||
workspaceDir: "/tmp/test-project-rebound",
|
||||
},
|
||||
});
|
||||
await seedFeishuConnection();
|
||||
|
||||
await archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-rebound",
|
||||
eventId: "event-before-rebind",
|
||||
reason: "bot_removed",
|
||||
});
|
||||
await prisma.projectGroupBinding.create({
|
||||
data: { projectId: "project-rebound", chatId: "chat-rebound" },
|
||||
});
|
||||
|
||||
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-rebound",
|
||||
eventId: "event-before-rebind",
|
||||
reason: "bot_removed",
|
||||
})).resolves.toEqual({ archived: false });
|
||||
await expect(prisma.projectGroupBinding.findFirst({
|
||||
where: { projectId: "project-rebound", archivedAt: null },
|
||||
select: { id: true },
|
||||
})).resolves.not.toBeNull();
|
||||
});
|
||||
|
||||
it("leaves unrelated active bindings untouched", async () => {
|
||||
await seedProject("project-kept", "chat-kept");
|
||||
|
||||
await expect(archiveFeishuBindingForLifecycleEvent(prisma, {
|
||||
chatId: "chat-unknown",
|
||||
eventId: "event-unknown",
|
||||
reason: "chat_dissolved",
|
||||
})).resolves.toEqual({ archived: false });
|
||||
|
||||
await expect(prisma.projectGroupBinding.findFirst({
|
||||
where: { chatId: "chat-kept", archivedAt: null },
|
||||
select: { id: true },
|
||||
})).resolves.not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
async function seedFeishuConnection(): Promise<void> {
|
||||
await prisma.organizationFeishuApplicationConnection.create({
|
||||
data: {
|
||||
id: CONNECTION_ID,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
appIdentityFingerprint: "fingerprint-lifecycle",
|
||||
},
|
||||
});
|
||||
await prisma.feishuApplicationCredentialVersion.create({
|
||||
data: {
|
||||
id: "feishu-secret-version-lifecycle",
|
||||
connectionId: CONNECTION_ID,
|
||||
version: 1,
|
||||
keyId: "test-active",
|
||||
envelope: { fixture: true },
|
||||
},
|
||||
});
|
||||
await prisma.organizationFeishuApplicationConnection.update({
|
||||
where: { id: CONNECTION_ID },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: "feishu-secret-version-lifecycle",
|
||||
activatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { CardActionEvent, FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { CardActionEvent, FeishuBindingLifecycleEvent, FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import { startFeishuListenerWithClient } from "../../src/feishu/client.js";
|
||||
|
||||
const larkMock = vi.hoisted(() => {
|
||||
@@ -80,6 +80,57 @@ describe("Feishu card action callbacks", () => {
|
||||
expect(logger.error).toHaveBeenCalledWith({ err }, "feishu card action handler threw");
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches chat dissolution and bot-removal lifecycle events", async () => {
|
||||
const onLifecycle = vi.fn(async (_event: FeishuBindingLifecycleEvent) => {});
|
||||
await startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
silentLogger(),
|
||||
async () => {},
|
||||
undefined,
|
||||
undefined,
|
||||
onLifecycle,
|
||||
);
|
||||
|
||||
await lifecycleHandler("im.chat.disbanded_v1")({
|
||||
event_id: "event-dissolved",
|
||||
chat_id: "chat-dissolved",
|
||||
});
|
||||
await lifecycleHandler("im.chat.member.bot.deleted_v1")({
|
||||
header: { event_id: "event-bot-removed" },
|
||||
chat_id: "chat-bot-removed",
|
||||
});
|
||||
|
||||
expect(onLifecycle).toHaveBeenNthCalledWith(1, {
|
||||
eventId: "event-dissolved",
|
||||
chatId: "chat-dissolved",
|
||||
reason: "chat_dissolved",
|
||||
});
|
||||
expect(onLifecycle).toHaveBeenNthCalledWith(2, {
|
||||
eventId: "event-bot-removed",
|
||||
chatId: "chat-bot-removed",
|
||||
reason: "bot_removed",
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates lifecycle archival failures to the WS dispatcher", async () => {
|
||||
const failure = new Error("archive failed");
|
||||
await startFeishuListenerWithClient(
|
||||
{ appId: "app-id", appSecret: "app-secret", botOpenId: "bot-open-id" },
|
||||
fakeClient(),
|
||||
silentLogger(),
|
||||
async () => {},
|
||||
undefined,
|
||||
undefined,
|
||||
async () => { throw failure; },
|
||||
);
|
||||
|
||||
await expect(lifecycleHandler("im.chat.disbanded_v1")({
|
||||
event_id: "event-failure",
|
||||
chat_id: "chat-failure",
|
||||
})).rejects.toThrow(failure);
|
||||
});
|
||||
});
|
||||
|
||||
function cardActionHandler(): (data: unknown) => Promise<void> {
|
||||
@@ -91,6 +142,15 @@ function cardActionHandler(): (data: unknown) => Promise<void> {
|
||||
return handler;
|
||||
}
|
||||
|
||||
function lifecycleHandler(eventType: string): (data: unknown) => Promise<void> {
|
||||
const start = larkMock.starts[0];
|
||||
if (start === undefined) throw new Error("WSClient.start was not called");
|
||||
const dispatcher = start.eventDispatcher as { readonly handlers?: Record<string, (data: unknown) => Promise<void>> };
|
||||
const handler = dispatcher.handlers?.[eventType];
|
||||
if (handler === undefined) throw new Error(`${eventType} handler was not registered`);
|
||||
return handler;
|
||||
}
|
||||
|
||||
function makeCardActionEvent(): CardActionEvent {
|
||||
return {
|
||||
operator: { open_id: "ou_user" },
|
||||
|
||||
Reference in New Issue
Block a user