Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/app/api/webhook/stripe/route.api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/server/api/routers/campaign-security.trpc.test.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
4 changes: 2 additions & 2 deletions apps/web/src/server/api/routers/campaign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
}
Expand Down
88 changes: 88 additions & 0 deletions apps/web/src/server/api/routers/team-security.trpc.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
16 changes: 10 additions & 6 deletions apps/web/src/server/api/routers/team.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ export const teamRouter = createTRPCRouter({
email: z.string(),
role: z.enum(["MEMBER", "ADMIN"]),
sendEmail: z.boolean().default(true),
})
}),
)
.mutation(async ({ ctx, input }) => {
return TeamService.createTeamInvite(
ctx.team.id,
input.email,
input.role,
ctx.team.name,
input.sendEmail
input.sendEmail,
);
}),

Expand All @@ -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,
);
}),

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
},
});
});
});
5 changes: 3 additions & 2 deletions apps/web/src/server/public-api/api/contacts/get-contact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});

Expand Down
Loading