feat(chat): scope agent to current organization
This commit is contained in:
300
packages/backend/convex/conversationMessages.test.ts
Normal file
300
packages/backend/convex/conversationMessages.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { convexTest } from "convex-test";
|
||||
import { anyApi } from "convex/server";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { requireOrganizationMember } from "./authz";
|
||||
import schema from "./schema";
|
||||
|
||||
declare global {
|
||||
interface ImportMeta {
|
||||
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
||||
}
|
||||
}
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
const api = anyApi;
|
||||
|
||||
const ID_A = "https://convex.test|user-a";
|
||||
const ID_B = "https://convex.test|user-b";
|
||||
|
||||
const identityA = { tokenIdentifier: ID_A };
|
||||
const identityB = { tokenIdentifier: ID_B };
|
||||
|
||||
const newTest = () => convexTest({ schema, modules });
|
||||
|
||||
const ensureOrg = async (
|
||||
t: ReturnType<typeof newTest>,
|
||||
identity: { readonly tokenIdentifier: string }
|
||||
) => {
|
||||
const org = await t
|
||||
.withIdentity(identity)
|
||||
.mutation(api.organizations.ensurePersonalOrganization, {});
|
||||
return org._id as string;
|
||||
};
|
||||
|
||||
describe("conversationMessages", () => {
|
||||
test("unauthenticated access is rejected", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
rawText: "hello",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
|
||||
await expect(
|
||||
t.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-1",
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
|
||||
await expect(
|
||||
t.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-1",
|
||||
organizationId: orgId,
|
||||
})
|
||||
).rejects.toThrow(/Authentication required/u);
|
||||
});
|
||||
|
||||
test("cross-organization access is denied for begin and observation", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const _orgB = await ensureOrg(t, identityB);
|
||||
|
||||
// User A captures a message in its own organization.
|
||||
const created = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "private to org A",
|
||||
});
|
||||
|
||||
// User B cannot begin a message under organization A.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
rawText: "intruder",
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// User B cannot observe organization A's message by request id.
|
||||
await expect(
|
||||
t.withIdentity(identityB).query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-cross",
|
||||
organizationId: orgA,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// User B cannot list organization A's conversation.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
})
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
// The membership boundary independently denies cross-org access.
|
||||
await expect(
|
||||
t
|
||||
.withIdentity(identityB)
|
||||
.query((ctx) => requireOrganizationMember(ctx, orgA as never))
|
||||
).rejects.toThrow(/Organization membership required/u);
|
||||
|
||||
expect(created.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("duplicate request id is idempotent and preserves exact whitespace and casing", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
const rawText = " Hello WORLD \n\t with spaces ";
|
||||
|
||||
const first = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText,
|
||||
});
|
||||
|
||||
// A second begin with the same request id returns the same row — no
|
||||
// duplicate is created.
|
||||
const second = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-dup",
|
||||
organizationId: orgId,
|
||||
rawText: "DIFFERENT TEXT MUST BE IGNORED",
|
||||
});
|
||||
|
||||
expect(second._id).toBe(first._id);
|
||||
expect(second.messageId).toBe(first.messageId);
|
||||
expect(second.rawText).toBe(rawText);
|
||||
expect(second.clientRequestId).toBe("req-dup");
|
||||
expect(second.role).toBe("user");
|
||||
expect(second.status).toBe("admitting");
|
||||
|
||||
// The conversation id equals the organization id (global agent scope).
|
||||
expect(first.conversationId).toBe(orgId);
|
||||
});
|
||||
|
||||
test("admitted transition records the submission id", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
rawText: "please help",
|
||||
});
|
||||
expect(begun.status).toBe("admitting");
|
||||
expect(begun.submissionId).toBeNull();
|
||||
|
||||
const admitted = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
|
||||
expect(admitted.status).toBe("admitted");
|
||||
expect(admitted.submissionId).toBe("sub-receipt-123");
|
||||
|
||||
// Marking admitted again is idempotent and keeps the same submission id.
|
||||
const again = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-admit",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-receipt-123",
|
||||
});
|
||||
expect(again.status).toBe("admitted");
|
||||
expect(again.submissionId).toBe("sub-receipt-123");
|
||||
});
|
||||
|
||||
test("failed transition records the failed status and preserves evidence", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
rawText: "doomed message",
|
||||
});
|
||||
|
||||
const failed = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
expect(failed.status).toBe("failed");
|
||||
expect(failed.submissionId).toBeNull();
|
||||
expect(failed.rawText).toBe("doomed message");
|
||||
|
||||
// A failed message is still observable as evidence.
|
||||
const observed = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.getByRequest, {
|
||||
clientRequestId: "req-fail",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(observed?.status).toBe("failed");
|
||||
expect(observed?.rawText).toBe("doomed message");
|
||||
});
|
||||
|
||||
test("admission wins over a concurrent failure and cannot be overwritten", async () => {
|
||||
const t = newTest();
|
||||
const orgId = await ensureOrg(t, identityA);
|
||||
|
||||
const begun = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
rawText: "race",
|
||||
});
|
||||
|
||||
// Admission completes first.
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markAdmitted, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
submissionId: "sub-won",
|
||||
});
|
||||
|
||||
// A late failure is a no-op: an admitted message keeps its submission id.
|
||||
const afterFail = await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.markFailed, {
|
||||
clientRequestId: "req-race",
|
||||
organizationId: orgId,
|
||||
});
|
||||
expect(afterFail.status).toBe("admitted");
|
||||
expect(afterFail.submissionId).toBe("sub-won");
|
||||
|
||||
expect(begun.status).toBe("admitting");
|
||||
});
|
||||
|
||||
test("list returns only the authenticated organization's messages", async () => {
|
||||
const t = newTest();
|
||||
const orgA = await ensureOrg(t, identityA);
|
||||
const orgB = await ensureOrg(t, identityB);
|
||||
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a1",
|
||||
organizationId: orgA,
|
||||
rawText: "a-one",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityA)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-a2",
|
||||
organizationId: orgA,
|
||||
rawText: "a-two",
|
||||
});
|
||||
await t
|
||||
.withIdentity(identityB)
|
||||
.mutation(api.conversationMessages.beginUserMessage, {
|
||||
clientRequestId: "req-b1",
|
||||
organizationId: orgB,
|
||||
rawText: "b-one",
|
||||
});
|
||||
|
||||
const forA = await t
|
||||
.withIdentity(identityA)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgA,
|
||||
});
|
||||
const forB = await t
|
||||
.withIdentity(identityB)
|
||||
.query(api.conversationMessages.listForCurrentOrganization, {
|
||||
organizationId: orgB,
|
||||
});
|
||||
|
||||
expect(forA).toHaveLength(2);
|
||||
expect(
|
||||
forA.map((m: { clientRequestId: string }) => m.clientRequestId)
|
||||
).toEqual(expect.arrayContaining(["req-a1", "req-a2"]));
|
||||
expect(forB).toHaveLength(1);
|
||||
expect(forB[0]?.clientRequestId).toBe("req-b1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user