fix: enforce team scoping for campaign, contacts, and invites (#356)

* fix: enforce team-scoped lookups for campaign contacts and invites

* fix(test): mock domain service in campaign security test
This commit is contained in:
KM Koushik
2026-02-23 11:30:05 +11:00
committed by GitHub
parent f7a0d11758
commit 61dfcee67d
8 changed files with 319 additions and 15 deletions
@@ -25,6 +25,12 @@ vi.mock("~/server/billing/payments", () => ({
syncStripeData: vi.fn(),
}));
vi.mock("~/env", () => ({
env: {
STRIPE_WEBHOOK_SECRET: undefined,
},
}));
import { POST } from "~/app/api/webhook/stripe/route";
describe("stripe webhook route", () => {
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockDb, mockValidateDomainFromEmail } = vi.hoisted(() => ({
mockDb: {
teamUser: {
findFirst: vi.fn(),
},
campaign: {
findUnique: vi.fn(),
update: vi.fn(),
},
contactBook: {
findUnique: vi.fn(),
},
},
mockValidateDomainFromEmail: vi.fn(),
}));
vi.mock("~/server/db", () => ({
db: mockDb,
}));
vi.mock("~/server/auth", () => ({
getServerAuthSession: vi.fn(),
}));
vi.mock("~/server/service/campaign-service", () => ({}));
vi.mock("~/server/service/webhook-service", () => ({}));
vi.mock("~/server/service/domain-service", () => ({
validateDomainFromEmail: mockValidateDomainFromEmail,
}));
import { createCallerFactory } from "~/server/api/trpc";
import { campaignRouter } from "~/server/api/routers/campaign";
const createCaller = createCallerFactory(campaignRouter);
function getContext() {
return {
db: mockDb,
headers: new Headers(),
session: {
user: {
id: 1,
email: "owner@example.com",
isWaitlisted: false,
isAdmin: false,
isBetaUser: true,
},
},
} as any;
}
describe("campaignRouter.updateCampaign authorization", () => {
beforeEach(() => {
mockDb.teamUser.findFirst.mockReset();
mockDb.campaign.findUnique.mockReset();
mockDb.campaign.update.mockReset();
mockDb.contactBook.findUnique.mockReset();
mockDb.teamUser.findFirst.mockResolvedValue({
teamId: 10,
userId: 1,
role: "ADMIN",
team: { id: 10, name: "Acme" },
});
mockDb.campaign.findUnique.mockResolvedValue({
id: "camp_1",
teamId: 10,
domainId: 2,
});
mockDb.campaign.update.mockResolvedValue({
id: "camp_1",
teamId: 10,
domainId: 2,
contactBookId: "cb_other_team",
});
});
it("rejects assigning a contact book from another team", async () => {
mockDb.contactBook.findUnique.mockResolvedValue(null);
const caller = createCaller(getContext());
await expect(
caller.updateCampaign({
campaignId: "camp_1",
contactBookId: "cb_other_team",
}),
).rejects.toMatchObject({
code: "BAD_REQUEST",
message: "Contact book not found",
});
expect(mockDb.contactBook.findUnique).toHaveBeenCalledWith({
where: {
id: "cb_other_team",
teamId: 10,
},
});
});
});
+2 -2
View File
@@ -128,7 +128,7 @@ export const campaignRouter = createTRPCRouter({
const { html: htmlInput, campaignId, ...data } = input;
if (data.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: data.contactBookId },
where: { id: data.contactBookId, teamId: team.id },
});
if (!contactBook) {
@@ -191,7 +191,7 @@ export const campaignRouter = createTRPCRouter({
if (campaign?.contactBookId) {
const contactBook = await db.contactBook.findUnique({
where: { id: campaign.contactBookId },
where: { id: campaign.contactBookId, teamId: team.id },
});
return { ...campaign, contactBook, imageUploadSupported };
}
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockDb, mockSendTeamInviteEmail } = vi.hoisted(() => ({
mockDb: {
teamUser: {
findFirst: vi.fn(),
},
teamInvite: {
findFirst: vi.fn(),
},
},
mockSendTeamInviteEmail: vi.fn(),
}));
vi.mock("~/server/db", () => ({
db: mockDb,
}));
vi.mock("~/server/auth", () => ({
getServerAuthSession: vi.fn(),
}));
vi.mock("~/server/mailer", () => ({
sendMail: vi.fn(),
sendTeamInviteEmail: mockSendTeamInviteEmail,
}));
vi.mock("~/server/service/webhook-service", () => ({}));
import { createCallerFactory } from "~/server/api/trpc";
import { teamRouter } from "~/server/api/routers/team";
const createCaller = createCallerFactory(teamRouter);
function getContext() {
return {
db: mockDb,
headers: new Headers(),
session: {
user: {
id: 1,
email: "admin@example.com",
isWaitlisted: false,
isAdmin: false,
isBetaUser: true,
},
},
} as any;
}
describe("teamRouter.resendTeamInvite authorization", () => {
beforeEach(() => {
mockDb.teamUser.findFirst.mockReset();
mockDb.teamInvite.findFirst.mockReset();
mockSendTeamInviteEmail.mockReset();
mockDb.teamUser.findFirst.mockResolvedValue({
teamId: 1,
userId: 1,
role: "ADMIN",
team: { id: 1, name: "Team One" },
});
});
it("does not resend invites that belong to another team", async () => {
mockDb.teamInvite.findFirst.mockResolvedValue(null);
const caller = createCaller(getContext());
await expect(
caller.resendTeamInvite({ inviteId: "invite_team_2" }),
).rejects.toMatchObject({
code: "NOT_FOUND",
message: "Invite not found",
});
expect(mockDb.teamInvite.findFirst).toHaveBeenCalledWith({
where: {
teamId: 1,
id: {
equals: "invite_team_2",
},
},
});
expect(mockSendTeamInviteEmail).not.toHaveBeenCalled();
});
});
+10 -6
View File
@@ -33,7 +33,7 @@ export const teamRouter = createTRPCRouter({
email: z.string(),
role: z.enum(["MEMBER", "ADMIN"]),
sendEmail: z.boolean().default(true),
})
}),
)
.mutation(async ({ ctx, input }) => {
return TeamService.createTeamInvite(
@@ -41,7 +41,7 @@ export const teamRouter = createTRPCRouter({
input.email,
input.role,
ctx.team.name,
input.sendEmail
input.sendEmail,
);
}),
@@ -50,13 +50,13 @@ export const teamRouter = createTRPCRouter({
z.object({
userId: z.string(),
role: z.enum(["MEMBER", "ADMIN"]),
})
}),
)
.mutation(async ({ ctx, input }) => {
return TeamService.updateTeamUserRole(
ctx.team.id,
input.userId,
input.role
input.role,
);
}),
@@ -67,14 +67,18 @@ export const teamRouter = createTRPCRouter({
ctx.team.id,
input.userId,
ctx.teamUser.role,
ctx.session.user.id
ctx.session.user.id,
);
}),
resendTeamInvite: teamAdminProcedure
.input(z.object({ inviteId: z.string() }))
.mutation(async ({ ctx, input }) => {
return TeamService.resendTeamInvite(input.inviteId, ctx.team.name);
return TeamService.resendTeamInvite(
ctx.team.id,
input.inviteId,
ctx.team.name,
);
}),
deleteTeamInvite: teamAdminProcedure
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockGetTeamFromToken, mockRedis, mockDb } = vi.hoisted(() => ({
mockGetTeamFromToken: vi.fn(),
mockRedis: {
incr: vi.fn(),
expire: vi.fn(),
ttl: vi.fn(),
},
mockDb: {
contactBook: {
findUnique: vi.fn(),
},
contact: {
findFirst: vi.fn(),
},
},
}));
vi.mock("~/server/public-api/auth", () => ({
getTeamFromToken: mockGetTeamFromToken,
}));
vi.mock("~/server/redis", () => ({
getRedis: () => mockRedis,
}));
vi.mock("~/server/db", () => ({
db: mockDb,
}));
vi.mock("~/utils/common", () => ({
isSelfHosted: () => false,
}));
import { getApp } from "~/server/public-api/hono";
import getContact from "~/server/public-api/api/contacts/get-contact";
describe("GET /v1/contactBooks/{contactBookId}/contacts/{contactId}", () => {
beforeEach(() => {
mockGetTeamFromToken.mockReset();
mockRedis.incr.mockReset();
mockRedis.expire.mockReset();
mockRedis.ttl.mockReset();
mockDb.contactBook.findUnique.mockReset();
mockDb.contact.findFirst.mockReset();
mockGetTeamFromToken.mockResolvedValue({
id: 1,
apiRateLimit: 20,
apiKeyId: 11,
apiKey: { domainId: null },
});
mockRedis.incr.mockResolvedValue(1);
mockRedis.expire.mockResolvedValue(1);
mockRedis.ttl.mockResolvedValue(1);
mockDb.contactBook.findUnique.mockResolvedValue({
id: "cb_team_1",
teamId: 1,
});
});
it("does not return a contact outside the requested contact book", async () => {
mockDb.contact.findFirst.mockResolvedValue(null);
const app = getApp();
getContact(app);
const response = await app.request(
"http://localhost/api/v1/contactBooks/cb_team_1/contacts/contact_other_team",
{
headers: {
Authorization: "Bearer test-key",
},
},
);
expect(response.status).toBe(404);
const body = await response.json();
expect(body).toMatchObject({
error: {
code: "NOT_FOUND",
},
});
expect(mockDb.contact.findFirst).toHaveBeenCalledWith({
where: {
id: "contact_other_team",
contactBookId: "cb_team_1",
},
});
});
});
@@ -52,13 +52,14 @@ function getContact(app: PublicAPIApp) {
app.openapi(route, async (c) => {
const team = c.var.team;
await getContactBook(c, team.id);
const contactBook = await getContactBook(c, team.id);
const contactId = c.req.param("contactId");
const contact = await db.contact.findUnique({
const contact = await db.contact.findFirst({
where: {
id: contactId,
contactBookId: contactBook.id,
},
});
+10 -5
View File
@@ -307,10 +307,17 @@ export class TeamService {
return deleted;
}
static async resendTeamInvite(inviteId: string, teamName: string) {
const invite = await db.teamInvite.findUnique({
static async resendTeamInvite(
teamId: number,
inviteId: string,
teamName: string,
) {
const invite = await db.teamInvite.findFirst({
where: {
id: inviteId,
teamId,
id: {
equals: inviteId,
},
},
});
@@ -448,7 +455,6 @@ export class TeamService {
);
throw err;
}
}
/**
@@ -555,7 +561,6 @@ export class TeamService {
);
throw err;
}
}
}