fix: enforce active organization boundary

This commit is contained in:
2026-07-10 18:42:12 +08:00
parent f07f280b8f
commit d730e51c3d
24 changed files with 1686 additions and 460 deletions
@@ -7,6 +7,13 @@ import { join } from "node:path";
import Fastify from "fastify";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { registerAdminPlugin } from "../../src/admin/plugin.js";
import {
archiveFolder,
archiveProject,
moveOrgProjectToFolder,
renameFolder,
renameProject,
} from "../../src/org/explorer.js";
import {
mintSessionToken,
sessionCookieHeader,
@@ -221,4 +228,59 @@ describe("admin explorer API", () => {
await app.close();
}
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"blocks direct explorer mutations when the organization is %s",
async (status) => {
await Promise.all([
prisma.folder.create({ data: { id: `folder-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename" } }),
prisma.folder.create({ data: { id: `folder-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive" } }),
prisma.folder.create({ data: { id: `folder-target-${status}`, organizationId: DEFAULT_ORG_ID, name: "Target" } }),
prisma.project.create({
data: { id: `project-rename-${status}`, organizationId: DEFAULT_ORG_ID, name: "Rename", workspaceDir: `/tmp/rename-${status}` },
}),
prisma.project.create({
data: { id: `project-archive-${status}`, organizationId: DEFAULT_ORG_ID, name: "Archive", workspaceDir: `/tmp/archive-${status}` },
}),
prisma.project.create({
data: { id: `project-move-${status}`, organizationId: DEFAULT_ORG_ID, name: "Move", workspaceDir: `/tmp/move-${status}` },
}),
]);
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const expected = `organization ${DEFAULT_ORG_ID} is ${status}`;
await expect(renameFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: `folder-rename-${status}`,
name: "Changed",
})).rejects.toThrow(expected);
await expect(archiveFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
folderId: `folder-archive-${status}`,
})).rejects.toThrow(expected);
await expect(renameProject(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-rename-${status}`,
name: "Changed",
})).rejects.toThrow(expected);
await expect(archiveProject(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-archive-${status}`,
})).rejects.toThrow(expected);
await expect(moveOrgProjectToFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
projectId: `project-move-${status}`,
folderId: `folder-target-${status}`,
})).rejects.toThrow(expected);
await expect(prisma.folder.findUniqueOrThrow({ where: { id: `folder-rename-${status}` } })).resolves.toMatchObject({
name: "Rename",
archivedAt: null,
});
await expect(prisma.project.findUniqueOrThrow({ where: { id: `project-rename-${status}` } })).resolves.toMatchObject({
name: "Rename",
archivedAt: null,
});
},
);
});
@@ -9,6 +9,8 @@ import {
sessionCookieHeader,
} from "../../src/admin/routes/authRoutes.js";
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
import { addOrgMember } from "../../src/org/members.js";
import { addTeamMember, archiveTeam, createTeam, updateTeam } from "../../src/org/teams.js";
const SESSION_SECRET = "integration-test-session-secret";
@@ -158,4 +160,49 @@ describe("admin members + teams API", () => {
await app.close();
}
});
it("blocks direct member and team mutations for a suspended organization", async () => {
await seedOwner();
await prisma.user.create({ data: { id: "u-direct-member", feishuOpenId: "ou_direct", displayName: "Direct" } });
await prisma.organizationMembership.create({
data: { organizationId: DEFAULT_ORG_ID, userId: "u-direct-member", role: "MEMBER" },
});
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "existing", name: "Existing" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const expected = `organization ${DEFAULT_ORG_ID} is SUSPENDED`;
await expect(addOrgMember(prisma, {
organizationId: DEFAULT_ORG_ID,
actorRole: "OWNER",
feishuOpenId: "ou_blocked",
role: "MEMBER",
})).rejects.toThrow(expected);
await expect(createTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
slug: "blocked",
name: "Blocked",
})).rejects.toThrow(expected);
await expect(updateTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
name: "Changed",
})).rejects.toThrow(expected);
await expect(addTeamMember(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
userId: "u-direct-member",
})).rejects.toThrow(expected);
await expect(archiveTeam(prisma, {
organizationId: DEFAULT_ORG_ID,
teamId: team.id,
})).rejects.toThrow(expected);
await expect(prisma.user.findUnique({ where: { feishuOpenId: "ou_blocked" } })).resolves.toBeNull();
await expect(prisma.team.findUniqueOrThrow({ where: { id: team.id } })).resolves.toMatchObject({
name: "Existing",
archivedAt: null,
});
});
});
+100
View File
@@ -7,6 +7,7 @@ import {
PrismaPrincipalResolver,
syncExternalPrincipalMemberships,
} from "../../src/permission.js";
import { lockActiveOrganization } from "../../src/org/status.js";
describe("ADR-0019 authorization", () => {
beforeEach(async () => {
@@ -96,6 +97,75 @@ describe("ADR-0019 authorization", () => {
});
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"denies every project authorization when the organization is %s",
async (status) => {
await prisma.project.create({
data: { id: `p-org-${status}`, organizationId: DEFAULT_ORG_ID, name: status, workspaceDir: `/tmp/org-${status}` },
});
await prisma.user.create({
data: {
id: `u-org-${status}`,
feishuOpenId: `ou_org_${status}`,
displayName: status,
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "OWNER" } },
},
});
await prisma.permissionGrant.create({
data: {
resourceType: "PROJECT",
resourceId: `p-org-${status}`,
principalType: "USER",
principalId: `ou_org_${status}`,
role: "MANAGE",
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
for (const action of ["project.read", "project.edit", "collaborator.manage", "agent.trigger", "agent.cancel"] as const) {
const decision = await createPermissionAuthorizer(prisma).can({
actor: { feishuOpenId: `ou_org_${status}` },
action,
resource: { type: "PROJECT", id: `p-org-${status}` },
});
expect(decision.allowed).toBe(false);
expect(decision.reason).toContain(`organization ${DEFAULT_ORG_ID} is ${status}`);
}
},
);
it("serializes a lifecycle transition behind an admitted customer transaction", async () => {
let releaseOperation!: () => void;
const operationHeld = new Promise<void>((resolve) => {
releaseOperation = resolve;
});
let markLocked!: () => void;
const rowLocked = new Promise<void>((resolve) => {
markLocked = resolve;
});
const operation = prisma.$transaction(async (tx) => {
await lockActiveOrganization(tx, DEFAULT_ORG_ID);
markLocked();
await operationHeld;
});
await rowLocked;
let transitionCommitted = false;
const transition = prisma.organization.update({
where: { id: DEFAULT_ORG_ID },
data: { status: "SUSPENDED" },
}).then(() => {
transitionCommitted = true;
});
await new Promise((resolve) => setTimeout(resolve, 100));
expect(transitionCommitted).toBe(false);
releaseOperation();
await operation;
await transition;
expect(transitionCommitted).toBe(true);
});
it("uses external Feishu principal memberships as grantable principals", async () => {
await prisma.project.create({
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
@@ -177,6 +247,36 @@ describe("ADR-0019 authorization", () => {
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
});
it("blocks directory sync and team grants for an archived organization", async () => {
const team = await prisma.team.create({
data: { organizationId: DEFAULT_ORG_ID, slug: "archived-team", name: "Archived Team" },
});
await prisma.project.create({
data: { id: "p-archived-grant", organizationId: DEFAULT_ORG_ID, name: "Archived", workspaceDir: "/tmp/archived" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "ARCHIVED" } });
const expected = `organization ${DEFAULT_ORG_ID} is ARCHIVED`;
await expect(syncExternalPrincipalMemberships(prisma, {
organizationId: DEFAULT_ORG_ID,
source: "blocked-sync",
memberships: [{
feishuOpenId: "ou_blocked_sync",
displayName: "Blocked",
principalType: "FEISHU_DEPARTMENT",
principalId: "dep-blocked",
}],
})).rejects.toThrow(expected);
await expect(grantTeamProjectAccess(prisma, {
projectId: "p-archived-grant",
teamId: team.id,
role: "EDIT",
})).rejects.toThrow(expected);
await expect(prisma.externalDirectoryConnection.count()).resolves.toBe(0);
await expect(prisma.permissionGrant.count({ where: { resourceId: "p-archived-grant" } })).resolves.toBe(0);
});
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
await prisma.project.create({
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
+125 -3
View File
@@ -1,7 +1,7 @@
import { mkdtemp, rm, stat } from "node:fs/promises";
import { join } from "node:path";
import { chmod, mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
import {
archiveFeishuChatBinding,
@@ -9,17 +9,21 @@ import {
createFolder,
createProjectFromFeishuChat,
createProjectFromOrgAdmin,
moveProjectToFolder,
setMembersCanCreateProjects,
} from "../../src/projectOnboarding.js";
const workspaceRoots: string[] = [];
const itAsNonRoot = typeof process.getuid === "function" && process.getuid() === 0 ? it.skip : it;
describe("ADR-0021 project onboarding", () => {
beforeEach(async () => {
await dropPermissionSettingsFailureTrigger();
await resetDb();
});
afterEach(async () => {
await dropPermissionSettingsFailureTrigger();
while (workspaceRoots.length > 0) {
const root = workspaceRoots.pop();
if (root !== undefined) await rm(root, { recursive: true, force: true });
@@ -27,6 +31,7 @@ describe("ADR-0021 project onboarding", () => {
});
afterAll(async () => {
await dropPermissionSettingsFailureTrigger();
await prisma.$disconnect();
});
@@ -111,6 +116,98 @@ describe("ADR-0021 project onboarding", () => {
})).rejects.toThrow(/members cannot create projects/);
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"blocks project creation and organization mutations when the org is %s",
async (status) => {
await seedUser(`u-${status}`, `ou_${status}`, "OWNER");
const folder = await createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: `Before ${status}`,
});
const workspaceRoot = await tempWorkspaceRoot();
const project = await createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: `ou_${status}`,
name: `Existing ${status}`,
workspaceRoot,
});
const organizationWorkspace = dirname(project.workspaceDir);
const workspacesBefore = (await readdir(organizationWorkspace)).sort();
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
await expect(createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: `ou_${status}`,
name: "Blocked",
workspaceRoot,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(readdir(organizationWorkspace)).resolves.toEqual(workspacesBefore);
await expect(bindFeishuChatToProject(prisma, {
projectId: project.projectId,
actorFeishuOpenId: `ou_${status}`,
chatId: `chat-${status}`,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(createFolder(prisma, {
organizationId: DEFAULT_ORG_ID,
name: "Blocked folder",
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(setMembersCanCreateProjects(prisma, {
organizationId: DEFAULT_ORG_ID,
enabled: false,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(moveProjectToFolder(prisma, {
projectId: project.projectId,
folderId: folder.id,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
},
);
it("removes a newly created workspace when the database transaction later fails", async () => {
await seedUser("u-compensate", "ou_compensate", "ADMIN");
await installPermissionSettingsFailureTrigger(0);
const workspaceRoot = await tempWorkspaceRoot();
await expect(createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_compensate",
name: "Must Roll Back",
workspaceRoot,
})).rejects.toThrow(/forced permission settings failure/);
await expect(readdir(join(workspaceRoot, "test-default"))).resolves.toEqual([]);
await expect(prisma.project.count()).resolves.toBe(0);
});
itAsNonRoot("surfaces both the transaction and workspace cleanup failures", async () => {
await seedUser("u-cleanup-failure", "ou_cleanup_failure", "ADMIN");
await installPermissionSettingsFailureTrigger(1);
const workspaceRoot = await tempWorkspaceRoot();
const organizationWorkspace = join(workspaceRoot, "test-default");
const pending = createProjectFromOrgAdmin(prisma, {
organizationId: DEFAULT_ORG_ID,
actorFeishuOpenId: "ou_cleanup_failure",
name: "Cleanup Must Fail Loudly",
workspaceRoot,
});
const outcome = pending.then(
() => null,
(error: unknown) => error,
);
await vi.waitFor(async () => {
expect(await readdir(organizationWorkspace)).toHaveLength(1);
}, { timeout: 2_000 });
await chmod(organizationWorkspace, 0o500);
try {
const error = await outcome;
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toHaveLength(2);
await expect(prisma.project.count()).resolves.toBe(0);
} finally {
await chmod(organizationWorkspace, 0o700);
}
});
it("binds an existing project once, archives the binding, then allows a new active binding", async () => {
await seedUser("u-owner", "ou_owner", "OWNER");
const workspaceRoot = await tempWorkspaceRoot();
@@ -176,3 +273,28 @@ async function tempWorkspaceRoot(): Promise<string> {
workspaceRoots.push(root);
return root;
}
async function installPermissionSettingsFailureTrigger(delaySeconds: number): Promise<void> {
await dropPermissionSettingsFailureTrigger();
await prisma.$executeRawUnsafe(`
CREATE FUNCTION cph_test_fail_permission_settings() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
PERFORM pg_sleep(${delaySeconds});
RAISE EXCEPTION 'forced permission settings failure';
END;
$$
`);
await prisma.$executeRawUnsafe(`
CREATE TRIGGER cph_test_fail_permission_settings
BEFORE INSERT ON "PermissionSettings"
FOR EACH ROW EXECUTE FUNCTION cph_test_fail_permission_settings()
`);
}
async function dropPermissionSettingsFailureTrigger(): Promise<void> {
await prisma.$executeRawUnsafe(
'DROP TRIGGER IF EXISTS cph_test_fail_permission_settings ON "PermissionSettings"',
);
await prisma.$executeRawUnsafe('DROP FUNCTION IF EXISTS cph_test_fail_permission_settings()');
}
@@ -68,4 +68,16 @@ describe("canTriggerRole (integration, per-role gate)", () => {
const onP2 = await canTriggerRole(prisma, p2.id, "review", "ou_a");
expect(onP2.allowed).toBe(false);
});
it("denies an otherwise open role when the organization is suspended", async () => {
const project = await prisma.project.create({
data: { id: "p-role-suspended", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const result = await canTriggerRole(prisma, project.id, "draft", "ou_a");
expect(result.allowed).toBe(false);
expect(result.reason).toContain(`organization ${DEFAULT_ORG_ID} is SUSPENDED`);
});
});
+240 -3
View File
@@ -1,10 +1,11 @@
import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from "node:fs/promises";
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 { InMemoryModelRegistry } from "../../src/agent/models.js";
import { createSlashCommandRegistry } from "../../src/feishu/slashCommands.js";
import { makeTriggerHandler as makeProductionTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
import { TriggerQueue } from "../../src/feishu/triggerQueue.js";
import type { MessageReceiveEvent, CardActionEvent } from "../../src/feishu/client.js";
@@ -164,7 +165,9 @@ describe("trigger full lifecycle (integration)", () => {
});
itOnLinux("includes downloaded post image paths in the agent prompt", async () => {
const workspaceDir = await tempWorkspaceRoot();
const workspaceRoot = await tempWorkspaceRoot();
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
await seedProject("proj-post-image", "chat-post-image");
await prisma.project.update({
where: { id: "proj-post-image" },
@@ -198,7 +201,7 @@ describe("trigger full lifecycle (integration)", () => {
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: dirname(workspaceDir),
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
});
@@ -212,6 +215,10 @@ describe("trigger full lifecycle (integration)", () => {
params: { type: "image" },
path: { message_id: event.message.message_id, file_key: "img-key-1" },
});
const inboxFiles = await readdir(join(workspaceDir, ".cph", "inbox"));
expect(inboxFiles).toHaveLength(1);
await expect(readFile(join(workspaceDir, ".cph", "inbox", inboxFiles[0]!))).resolves.toEqual(Buffer.from("image bytes"));
await expect(readdir(join(workspaceRoot, ".cph-staging"))).resolves.toEqual([]);
});
it("sends an onboarding card when an unbound chat mentions the bot", async () => {
@@ -612,6 +619,115 @@ describe("trigger full lifecycle (integration)", () => {
expect(runs).toHaveLength(0);
});
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects triggers and resume commands when the organization is %s",
async (status) => {
await seedProject(`proj-org-${status}`, `chat-org-${status}`);
const session = await prisma.agentSession.create({
data: {
projectId: `proj-org-${status}`,
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
await trigger(makeEvent(`chat-org-${status}`, "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("无权限触发。");
expect(runAgentCalls).toHaveLength(0);
expect(await prisma.agentRun.count()).toBe(0);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
},
);
it.each(["SUSPENDED", "ARCHIVED"] as const)(
"rejects direct session mutation when the organization is %s",
async (status) => {
await seedProject(`proj-direct-${status}`, `chat-direct-${status}`);
const session = await prisma.agentSession.create({
data: {
projectId: `proj-direct-${status}`,
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status } });
const commands = createSlashCommandRegistry({
prisma,
settings,
logger: silentLogger,
triggerQueue: new TriggerQueue(),
});
const resume = commands.get("resume");
expect(resume).toBeDefined();
await expect(resume!.run({
invocation: { name: "resume", args: [] },
projectId: `proj-direct-${status}`,
chatId: `chat-direct-${status}`,
rt,
})).rejects.toThrow(`organization ${DEFAULT_ORG_ID} is ${status}`);
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
},
);
it("reports a lifecycle race that rejects a slash-command mutation", async () => {
await seedProject("proj-slash-race", "chat-slash-race");
const session = await prisma.agentSession.create({
data: {
projectId: "proj-slash-race",
provider: "openrouter",
roleId: "draft",
model: "mock-model",
metadata: {},
archivedAt: new Date(),
},
});
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
authorizer: {
async can(request) {
return {
allowed: true,
reason: "authorized before concurrent suspension",
action: request.action,
resource: request.resource,
actor: request.actor,
organizationId: DEFAULT_ORG_ID,
principals: [{ type: "USER", id: "ou_test_user" }],
requiredRole: "EDIT",
effectiveRole: "EDIT",
};
},
},
});
await trigger(makeEvent("chat-slash-race", "@_user_1 /resume"), rt);
expect(rt.sentTexts).toContain("组织当前不可用,拒绝操作。");
await expect(prisma.agentSession.findUniqueOrThrow({ where: { id: session.id } })).resolves.toMatchObject({
archivedAt: expect.any(Date),
});
});
it("queues a text trigger when project is already locked (ADR-0002)", async () => {
await seedProject("proj-3", "chat-3");
// Manually create a lock by inserting a run + lock.
@@ -678,6 +794,127 @@ describe("trigger full lifecycle (integration)", () => {
});
});
it("reauthorizes a queued trigger and drops it after the organization is suspended", async () => {
await seedProject("proj-queue-suspended", "chat-queue-suspended");
const firstRun = deferred<RunResult>();
const queuedRunAgent: TestRunner = async (req) => {
runAgentCalls.push(req);
return firstRun.promise;
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent: queuedRunAgent,
messageBatcherOptions: { maxMessages: 1 },
});
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第一个请求"), rt);
await vi.waitFor(() => expect(runAgentCalls).toHaveLength(1));
await trigger(makeEvent("chat-queue-suspended", "@_user_1 第二个请求"), rt);
expect(rt.sentTexts).toContain("已加入队列(第1位),当前处理完成后将自动开始");
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
firstRun.resolve(completedRunResult("first done", "sdk-session-first"));
await vi.waitFor(() => expect(rt.sentTexts).toContain("无权限使用角色 draft。"));
expect(runAgentCalls).toHaveLength(1);
await expect(prisma.agentRun.count()).resolves.toBe(1);
});
it("rechecks ACTIVE atomically when suspension lands after the first authorization", async () => {
await seedProject("proj-admission-race", "chat-admission-race");
const providerEntered = deferred<void>();
const releaseProvider = deferred<void>();
const baseProvider = settings.provider.bind(settings);
const pausedSettings: RuntimeSettings = {
...settings,
async provider(providerId, context) {
providerEntered.resolve();
await releaseProvider.promise;
return baseProvider(providerId, context);
},
};
const trigger = makeTriggerHandler({
prisma,
settings: pausedSettings,
logger: silentLogger,
runAgent,
messageBatcherOptions: { maxMessages: 1 },
});
const pendingTrigger = trigger(makeEvent("chat-admission-race", "@_user_1 竞态请求"), rt);
await providerEntered.promise;
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
releaseProvider.resolve();
await pendingTrigger;
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
expect(runAgentCalls).toHaveLength(0);
await expect(prisma.agentSession.count()).resolves.toBe(0);
await expect(prisma.agentRun.count()).resolves.toBe(0);
await expect(prisma.projectAgentLock.count()).resolves.toBe(0);
await expect(prisma.auditEntry.count({ where: { action: "run.created" } })).resolves.toBe(0);
});
itOnLinux("does not publish a staged attachment when suspension wins admission", async () => {
const workspaceRoot = await tempWorkspaceRoot();
const workspaceDir = join(workspaceRoot, "project");
await mkdir(workspaceDir);
await seedProject("proj-attachment-race", "chat-attachment-race");
await prisma.project.update({
where: { id: "proj-attachment-race" },
data: { workspaceDir },
});
const resourceEntered = deferred<void>();
const releaseResource = deferred<void>();
const messageResourceGet = vi.fn(async () => {
resourceEntered.resolve();
await releaseResource.promise;
return { getReadableStream: () => Readable.from([Buffer.from("staged image bytes")]) };
});
const imV1 = (rt.client as unknown as {
im: { v1: { messageResource?: { get: typeof messageResourceGet } } };
}).im.v1;
imV1.messageResource = { get: messageResourceGet };
const baseEvent = makeEvent("chat-attachment-race", "@_user_1 附件竞态");
const event: MessageReceiveEvent = {
...baseEvent,
message: {
...baseEvent.message,
message_type: "post",
content: JSON.stringify({
zh_cn: {
content: [[
{ tag: "text", text: "@_user_1 附件竞态" },
{ tag: "img", image_key: "img-race" },
]],
},
}),
},
};
const trigger = makeTriggerHandler({
prisma,
settings,
logger: silentLogger,
runAgent,
projectWorkspaceRoot: workspaceRoot,
messageBatcherOptions: { maxMessages: 1 },
});
const pendingTrigger = trigger(event, rt);
await resourceEntered.promise;
await prisma.organization.update({ where: { id: DEFAULT_ORG_ID }, data: { status: "SUSPENDED" } });
releaseResource.resolve();
await pendingTrigger;
expect(rt.sentTexts).toContain("组织当前不可用,拒绝触发。");
expect(runAgentCalls).toHaveLength(0);
await expect(prisma.agentRun.count()).resolves.toBe(0);
await expect(readdir(join(workspaceDir, ".cph", "inbox"))).rejects.toMatchObject({ code: "ENOENT" });
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 () => {
await seedProject("proj-4", "chat-4");
const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent, messageBatcherOptions: { maxMessages: 1 } });
+6 -1
View File
@@ -250,7 +250,7 @@ function mockPrisma(): PrismaClient {
let lock: { projectId: string; runId: string } | null = null;
const session = { id: "session-1", metadata: {} };
return {
const client = {
feishuEventReceipt: {
findUnique: vi.fn(async () => null),
create: vi.fn(async () => ({ id: "receipt-1" })),
@@ -286,6 +286,11 @@ function mockPrisma(): PrismaClient {
create: vi.fn(async () => ({ id: "audit-1" })),
},
} as unknown as PrismaClient;
Object.assign(client, {
$transaction: vi.fn(async (callback: (tx: PrismaClient) => Promise<unknown>) => callback(client)),
$queryRaw: vi.fn(async () => [{ id: "org_test_default", status: "ACTIVE" }]),
});
return client;
}
function silentLogger(): FeishuRuntime["logger"] {
+39
View File
@@ -0,0 +1,39 @@
import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { removeAbandonedMessageResourceStages } from "../../src/feishu/resourceStaging.js";
const roots: string[] = [];
describe("Feishu resource staging recovery", () => {
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
it("removes every abandoned batch during startup recovery", async () => {
const root = await tempRoot();
const batch = join(root, ".cph-staging", "abandoned-batch");
await mkdir(batch, { recursive: true });
await writeFile(join(batch, "resource-0"), "sensitive attachment");
await expect(removeAbandonedMessageResourceStages(root)).resolves.toBe(1);
await expect(readdir(join(root, ".cph-staging"))).resolves.toEqual([]);
});
it("refuses to traverse a staging-base symlink", async () => {
const root = await tempRoot();
const outside = await tempRoot();
await symlink(outside, join(root, ".cph-staging"));
await expect(removeAbandonedMessageResourceStages(root)).rejects.toThrow(
/staging base is not a real directory/,
);
});
});
async function tempRoot(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), "cph-resource-staging-test-"));
roots.push(root);
return root;
}