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(),
|
syncStripeData: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/env", () => ({
|
||||||
|
env: {
|
||||||
|
STRIPE_WEBHOOK_SECRET: undefined,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { POST } from "~/app/api/webhook/stripe/route";
|
import { POST } from "~/app/api/webhook/stripe/route";
|
||||||
|
|
||||||
describe("stripe webhook 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;
|
const { html: htmlInput, campaignId, ...data } = input;
|
||||||
if (data.contactBookId) {
|
if (data.contactBookId) {
|
||||||
const contactBook = await db.contactBook.findUnique({
|
const contactBook = await db.contactBook.findUnique({
|
||||||
where: { id: data.contactBookId },
|
where: { id: data.contactBookId, teamId: team.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!contactBook) {
|
if (!contactBook) {
|
||||||
@@ -191,7 +191,7 @@ export const campaignRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (campaign?.contactBookId) {
|
if (campaign?.contactBookId) {
|
||||||
const contactBook = await db.contactBook.findUnique({
|
const contactBook = await db.contactBook.findUnique({
|
||||||
where: { id: campaign.contactBookId },
|
where: { id: campaign.contactBookId, teamId: team.id },
|
||||||
});
|
});
|
||||||
return { ...campaign, contactBook, imageUploadSupported };
|
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(),
|
email: z.string(),
|
||||||
role: z.enum(["MEMBER", "ADMIN"]),
|
role: z.enum(["MEMBER", "ADMIN"]),
|
||||||
sendEmail: z.boolean().default(true),
|
sendEmail: z.boolean().default(true),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return TeamService.createTeamInvite(
|
return TeamService.createTeamInvite(
|
||||||
@@ -41,7 +41,7 @@ export const teamRouter = createTRPCRouter({
|
|||||||
input.email,
|
input.email,
|
||||||
input.role,
|
input.role,
|
||||||
ctx.team.name,
|
ctx.team.name,
|
||||||
input.sendEmail
|
input.sendEmail,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -50,13 +50,13 @@ export const teamRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
role: z.enum(["MEMBER", "ADMIN"]),
|
role: z.enum(["MEMBER", "ADMIN"]),
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
return TeamService.updateTeamUserRole(
|
return TeamService.updateTeamUserRole(
|
||||||
ctx.team.id,
|
ctx.team.id,
|
||||||
input.userId,
|
input.userId,
|
||||||
input.role
|
input.role,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -67,14 +67,18 @@ export const teamRouter = createTRPCRouter({
|
|||||||
ctx.team.id,
|
ctx.team.id,
|
||||||
input.userId,
|
input.userId,
|
||||||
ctx.teamUser.role,
|
ctx.teamUser.role,
|
||||||
ctx.session.user.id
|
ctx.session.user.id,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
resendTeamInvite: teamAdminProcedure
|
resendTeamInvite: teamAdminProcedure
|
||||||
.input(z.object({ inviteId: z.string() }))
|
.input(z.object({ inviteId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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
|
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) => {
|
app.openapi(route, async (c) => {
|
||||||
const team = c.var.team;
|
const team = c.var.team;
|
||||||
|
|
||||||
await getContactBook(c, team.id);
|
const contactBook = await getContactBook(c, team.id);
|
||||||
|
|
||||||
const contactId = c.req.param("contactId");
|
const contactId = c.req.param("contactId");
|
||||||
|
|
||||||
const contact = await db.contact.findUnique({
|
const contact = await db.contact.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: contactId,
|
id: contactId,
|
||||||
|
contactBookId: contactBook.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -307,10 +307,17 @@ export class TeamService {
|
|||||||
return deleted;
|
return deleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async resendTeamInvite(inviteId: string, teamName: string) {
|
static async resendTeamInvite(
|
||||||
const invite = await db.teamInvite.findUnique({
|
teamId: number,
|
||||||
|
inviteId: string,
|
||||||
|
teamName: string,
|
||||||
|
) {
|
||||||
|
const invite = await db.teamInvite.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: inviteId,
|
teamId,
|
||||||
|
id: {
|
||||||
|
equals: inviteId,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -448,7 +455,6 @@ export class TeamService {
|
|||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -555,7 +561,6 @@ export class TeamService {
|
|||||||
);
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user