forked from EduCraft/curriculum-project-hub
fix: guide Feishu users through onboarding
This commit is contained in:
@@ -3,7 +3,15 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, mockFeishuRuntime, seedProject, silentLogger } from "./helpers.js";
|
||||
import {
|
||||
DEFAULT_ORG_ID,
|
||||
prisma,
|
||||
resetDb,
|
||||
mockFeishuRuntime,
|
||||
seedProject,
|
||||
seedTestOrganization,
|
||||
silentLogger,
|
||||
} from "./helpers.js";
|
||||
import { InMemoryModelRegistry } from "../../src/agent/models.js";
|
||||
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
|
||||
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||
@@ -24,6 +32,8 @@ type TestTriggerDeps = Omit<Parameters<typeof makeProductionTriggerHandler>[0],
|
||||
function makeTriggerHandler(deps: TestTriggerDeps): ReturnType<typeof makeProductionTriggerHandler> {
|
||||
return makeProductionTriggerHandler({
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: DEFAULT_ORG_ID,
|
||||
allowLegacyFeishuIdentity: true,
|
||||
...deps,
|
||||
});
|
||||
@@ -386,6 +396,18 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
role: "EDIT",
|
||||
},
|
||||
});
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{ id: "u-speaker-alice", feishuOpenId: "ou_alice", displayName: "Alice" },
|
||||
{ id: "u-speaker-bob", feishuOpenId: "ou_bob", displayName: "Bob" },
|
||||
],
|
||||
});
|
||||
await prisma.organizationMembership.createMany({
|
||||
data: [
|
||||
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-alice", role: "MEMBER" },
|
||||
{ organizationId: DEFAULT_ORG_ID, userId: "u-speaker-bob", role: "MEMBER" },
|
||||
],
|
||||
});
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-speaker", "@_user_1 Alice 的需求", "ou_alice"), rt);
|
||||
@@ -626,6 +648,66 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("gives an unknown user the scoped login URL in an already-bound chat", async () => {
|
||||
await seedProject("proj-bound-unknown", "chat-bound-unknown");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-unknown", "@_user_1 写教案", "ou_bound_unknown"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" +
|
||||
"登录后仍需由组织管理员将你加入组织。",
|
||||
);
|
||||
expect(rt.sentTexts).not.toContain("无权限触发。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("tells a logged-in non-member to contact the administrator in an already-bound chat", async () => {
|
||||
await seedProject("proj-bound-non-member", "chat-bound-non-member");
|
||||
await seedScopedIdentityWithoutMembership("bound-non-member", "ou_bound_non_member");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-non-member", "@_user_1 写教案", "ou_bound_non_member"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
);
|
||||
expect(rt.sentTexts).not.toContain("无权限触发。");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps generic project denial for an Organization member without project permission", async () => {
|
||||
await seedProject("proj-bound-read-only", "chat-bound-read-only", { role: "READ" });
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-bound-read-only", "@_user_1 写教案"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("无权限触发。");
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("尚未加入该组织");
|
||||
expect(runAgentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(["SUSPENDED", "ARCHIVED"] as const)(
|
||||
"rejects triggers and resume commands when the organization is %s",
|
||||
async (status) => {
|
||||
@@ -922,18 +1004,62 @@ describe("trigger full lifecycle (integration)", () => {
|
||||
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not create a run for unbound chats and asks unknown users to log in", async () => {
|
||||
it("does not create a run for unbound chats and gives unknown users the scoped login URL", async () => {
|
||||
await seedProject("proj-4", "chat-4");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
|
||||
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案", "ou_unknown_user"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain("请先登录并加入组织后,再绑定项目。");
|
||||
expect(rt.sentTexts).toContain(
|
||||
"请先通过飞书登录建立身份:https://educraft.example.test/auth/feishu/test-default\n" +
|
||||
"登录后仍需由组织管理员将你加入组织。",
|
||||
);
|
||||
expect(rt.sentCards).toHaveLength(0);
|
||||
const runs = await prisma.agentRun.findMany();
|
||||
expect(runs).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("distinguishes a scoped Feishu identity that has not joined the Silo Organization", async () => {
|
||||
await seedScopedIdentityWithoutMembership("unbound-non-member", "ou_logged_in_not_member");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
allowLegacyFeishuIdentity: false,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_logged_in_not_member"), rt);
|
||||
|
||||
expect(rt.sentTexts).toContain(
|
||||
"你已完成飞书登录,但尚未加入该组织。请联系组织管理员为你开通成员权限。",
|
||||
);
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("/auth/feishu/");
|
||||
await expect(prisma.agentRun.count()).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("encodes the configured Organization slug in the OAuth login URL", async () => {
|
||||
const encodedOrgId = "org_url_encoding";
|
||||
await seedTestOrganization(encodedOrgId, "school east/数学?");
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings,
|
||||
logger: silentLogger,
|
||||
runAgent,
|
||||
siloOrganizationId: encodedOrgId,
|
||||
publicBaseUrl: "https://school.example.test/",
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
|
||||
await trigger(makeEvent("chat-unbound", "@_user_1 写教案", "ou_unknown_encoded"), rt);
|
||||
|
||||
expect(rt.sentTexts.join("\n")).toContain(
|
||||
"https://school.example.test/auth/feishu/school%20east%2F%E6%95%B0%E5%AD%A6%3F",
|
||||
);
|
||||
expect(rt.sentTexts.join("\n")).not.toContain("school east/数学?");
|
||||
});
|
||||
|
||||
it("ignores messages without @bot mention", async () => {
|
||||
await seedProject("proj-5", "chat-5");
|
||||
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
|
||||
@@ -1430,6 +1556,28 @@ async function seedOnboardingUser(id: string, feishuOpenId: string, role: "OWNER
|
||||
});
|
||||
}
|
||||
|
||||
async function seedScopedIdentityWithoutMembership(id: string, openId: string): Promise<void> {
|
||||
const connectionId = `feishu-connection-${id}`;
|
||||
await prisma.organizationFeishuApplicationConnection.create({
|
||||
data: {
|
||||
id: connectionId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
appIdentityFingerprint: `fingerprint-${id}`,
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: `user-${id}`,
|
||||
displayName: "Logged in user",
|
||||
feishuOpenId: `legacy-${openId}`,
|
||||
feishuIdentities: {
|
||||
create: { connectionId, openId },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function tempWorkspaceRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "cph-trigger-onboarding-"));
|
||||
workspaceRoots.push(root);
|
||||
|
||||
@@ -110,6 +110,8 @@ describe("Feishu approval cards", () => {
|
||||
logger: silentLogger(),
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: "org_test_default",
|
||||
runAgent: async () => ({
|
||||
status: "completed",
|
||||
text: "",
|
||||
|
||||
@@ -103,6 +103,8 @@ async function triggerWithRunAgent(
|
||||
logger: rt.logger,
|
||||
authorizer: allowAllAuthorizer(),
|
||||
projectWorkspaceRoot: "/tmp",
|
||||
publicBaseUrl: "https://educraft.example.test",
|
||||
siloOrganizationId: "org_test_default",
|
||||
runAgent,
|
||||
messageBatcherOptions: { maxMessages: 1 },
|
||||
});
|
||||
@@ -254,6 +256,19 @@ function mockPrisma(): PrismaClient {
|
||||
const session = { id: "session-1", metadata: {} };
|
||||
|
||||
const client = {
|
||||
organization: {
|
||||
findFirst: vi.fn(async () => ({ id: "org_test_default", slug: "test-default" })),
|
||||
},
|
||||
feishuUserIdentity: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
user: {
|
||||
organizationMemberships: [{
|
||||
role: "OWNER",
|
||||
organization: { id: "org_test_default", name: "Test Organization" },
|
||||
}],
|
||||
},
|
||||
})),
|
||||
},
|
||||
feishuEventReceipt: {
|
||||
findUnique: vi.fn(async () => null),
|
||||
create: vi.fn(async () => ({ id: "receipt-1" })),
|
||||
|
||||
Reference in New Issue
Block a user