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
+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 } });