diff --git a/apps/web/src/app/api/webhook/stripe/route.api.test.ts b/apps/web/src/app/api/webhook/stripe/route.api.test.ts index 1b792d87..02eea44d 100644 --- a/apps/web/src/app/api/webhook/stripe/route.api.test.ts +++ b/apps/web/src/app/api/webhook/stripe/route.api.test.ts @@ -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", () => { diff --git a/apps/web/src/server/api/routers/campaign-security.trpc.test.ts b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts new file mode 100644 index 00000000..e320ce1c --- /dev/null +++ b/apps/web/src/server/api/routers/campaign-security.trpc.test.ts @@ -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, + }, + }); + }); +}); diff --git a/apps/web/src/server/api/routers/campaign.ts b/apps/web/src/server/api/routers/campaign.ts index 35a12480..5d2e27e4 100644 --- a/apps/web/src/server/api/routers/campaign.ts +++ b/apps/web/src/server/api/routers/campaign.ts @@ -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 }; } diff --git a/apps/web/src/server/api/routers/team-security.trpc.test.ts b/apps/web/src/server/api/routers/team-security.trpc.test.ts new file mode 100644 index 00000000..b181bd20 --- /dev/null +++ b/apps/web/src/server/api/routers/team-security.trpc.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/server/api/routers/team.ts b/apps/web/src/server/api/routers/team.ts index c9387763..99ba4182 100644 --- a/apps/web/src/server/api/routers/team.ts +++ b/apps/web/src/server/api/routers/team.ts @@ -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 diff --git a/apps/web/src/server/public-api/api/contacts/get-contact.api.test.ts b/apps/web/src/server/public-api/api/contacts/get-contact.api.test.ts new file mode 100644 index 00000000..e0fa33f9 --- /dev/null +++ b/apps/web/src/server/public-api/api/contacts/get-contact.api.test.ts @@ -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", + }, + }); + }); +}); diff --git a/apps/web/src/server/public-api/api/contacts/get-contact.ts b/apps/web/src/server/public-api/api/contacts/get-contact.ts index 090816f3..e01c7bb7 100644 --- a/apps/web/src/server/public-api/api/contacts/get-contact.ts +++ b/apps/web/src/server/public-api/api/contacts/get-contact.ts @@ -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, }, }); diff --git a/apps/web/src/server/service/team-service.ts b/apps/web/src/server/service/team-service.ts index 48653423..a4f97fb5 100644 --- a/apps/web/src/server/service/team-service.ts +++ b/apps/web/src/server/service/team-service.ts @@ -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; } - } }