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:
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user