From b174f4b3451da637dfbfa7d7f8e1562a9fabb598 Mon Sep 17 00:00:00 2001 From: Lana Date: Mon, 5 Oct 2026 09:01:37 +0300 Subject: [PATCH] fix(access): expose membership role in company user directory Refs: ZOL-15626 Co-Authored-By: Paperclip --- .../company-user-directory-route.test.ts | 34 ++++++++++++++++--- server/src/routes/access.ts | 2 ++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/company-user-directory-route.test.ts b/server/src/__tests__/company-user-directory-route.test.ts index c56ea4e0d49..5b1ab7d5aa4 100644 --- a/server/src/__tests__/company-user-directory-route.test.ts +++ b/server/src/__tests__/company-user-directory-route.test.ts @@ -26,8 +26,8 @@ vi.mock("../services/index.js", () => ({ function createDbStub() { const activeMemberships = [ - { principalId: "user-2", status: "active" as const }, - { principalId: "user-1", status: "active" as const }, + { principalId: "user-2", status: "active" as const, membershipRole: null }, + { principalId: "user-1", status: "active" as const, membershipRole: "owner" }, ]; const users = [ { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, @@ -48,7 +48,7 @@ function createDbStub() { "updatedAt" in table; return { - select() { + select(fields: Record) { return { from(table: unknown) { if (isCompanyMembershipsTable(table)) { @@ -57,7 +57,9 @@ function createDbStub() { return query; }, orderBy() { - return Promise.resolve(activeMemberships); + return Promise.resolve(activeMemberships.map((member) => Object.fromEntries( + Object.keys(fields).map((key) => [key, member[key as keyof typeof member]]), + ))); }, }; return query; @@ -119,14 +121,38 @@ describe("GET /companies/:companyId/user-directory", () => { { principalId: "user-2", status: "active", + membershipRole: null, user: { id: "user-2", name: null, email: "alex@example.com", image: null }, }, { principalId: "user-1", status: "active", + membershipRole: "owner", user: { id: "user-1", name: "Dotta", email: "dotta@example.com", image: "https://example.com/dotta.png" }, }, ], }); }); + + it("lets a company agent read roles without allowing membership management", async () => { + const app = createApp({ type: "agent", agentId: "agent-1", companyId: "company-1", source: "agent_key" }); + const res = await request(app).get("/api/companies/company-1/user-directory"); + expect(res.status).toBe(200); + expect(res.body.users.map(({ principalId, status, membershipRole }: any) => ({ principalId, status, membershipRole }))).toEqual([ + { principalId: "user-2", status: "active", membershipRole: null }, + { principalId: "user-1", status: "active", membershipRole: "owner" }, + ]); + expect((await request(app).get("/api/companies/company-1/members")).status).toBe(403); + expect((await request(app).patch("/api/companies/company-1/members/member-1").send({ membershipRole: "owner" })).status).toBe(403); + }); + + it("rejects another company and unauthenticated callers", async () => { + for (const actor of [ + { type: "agent", agentId: "agent-1", companyId: "company-2", source: "agent_key" } as const, + { type: "none", source: "none" } as const, + ]) { + const res = await request(createApp(actor)).get("/api/companies/company-1/user-directory"); + expect(res.status).toBe(actor.type === "none" ? 401 : 403); + } + }); }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index d35c4586d7b..cccc84f4d44 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -1379,6 +1379,7 @@ async function loadCompanyUserDirectory(db: Db, companyId: string) { .select({ principalId: companyMemberships.principalId, status: companyMemberships.status, + membershipRole: companyMemberships.membershipRole, }) .from(companyMemberships) .where( @@ -1396,6 +1397,7 @@ async function loadCompanyUserDirectory(db: Db, companyId: string) { return members.map((member) => ({ principalId: member.principalId, status: "active" as const, + membershipRole: member.membershipRole, user: userMap.get(member.principalId) ?? null, })); }