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
31 changes: 31 additions & 0 deletions src/api/routers/integrationsDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,35 @@ export const integrationsDiscoveryRouter = router({
});
}
}),

/**
* Verify a Sentry API token and organization slug.
* Used by the Integrations tab Alerting credential inputs.
* Accepts plaintext credentials from the form and calls the Sentry API to verify.
* The token is never stored by this endpoint.
*/
verifySentry: protectedProcedure
.input(z.object({ apiToken: z.string().min(1), organizationSlug: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
logger.debug('integrationsDiscovery.verifySentry called', { orgId: ctx.effectiveOrgId });
return wrapIntegrationCall('Failed to verify Sentry credentials', async () => {
const url = `https://sentry.io/api/0/organizations/${encodeURIComponent(input.organizationSlug)}/`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${input.apiToken}` },
});
if (!response.ok) {
throw new Error(`Sentry API returned ${response.status}: ${response.statusText}`);
}
const data = (await response.json()) as {
id?: string;
name?: string;
slug?: string;
};
return {
id: data.id ?? '',
name: data.name ?? '',
slug: data.slug ?? '',
};
});
}),
});
6 changes: 3 additions & 3 deletions src/api/routers/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
category: z.enum(['pm', 'scm']),
category: z.enum(['pm', 'scm', 'alerting']),
provider: z.string().min(1),
config: z.record(z.unknown()),
triggers: z.record(z.boolean()).optional(),
Expand All @@ -197,7 +197,7 @@ export const projectsRouter = router({
.input(
z.object({
projectId: z.string(),
category: z.enum(['pm', 'scm']),
category: z.enum(['pm', 'scm', 'alerting']),
triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])),
}),
)
Expand All @@ -207,7 +207,7 @@ export const projectsRouter = router({
}),

delete: protectedProcedure
.input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) }))
.input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'alerting']) }))
.mutation(async ({ ctx, input }) => {
await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId);
await deleteProjectIntegration(input.projectId, input.category);
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/api/routers/integrationsDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
mockVerifyProjectOrgAccess,
mockGetIntegrationCredentialOrNull,
mockGetIntegrationByProjectAndCategory,
mockFetch,
} = vi.hoisted(() => ({
mockTrelloGetMe: vi.fn(),
mockTrelloGetBoards: vi.fn(),
Expand All @@ -36,6 +37,7 @@ const {
mockVerifyProjectOrgAccess: vi.fn(),
mockGetIntegrationCredentialOrNull: vi.fn(),
mockGetIntegrationByProjectAndCategory: vi.fn(),
mockFetch: vi.fn(),
}));

vi.mock('../../../../src/trello/client.js', () => ({
Expand Down Expand Up @@ -106,10 +108,14 @@ const jiraCredsInput = {
baseUrl: 'https://myorg.atlassian.net',
};

// Assign global fetch mock
vi.stubGlobal('fetch', mockFetch);

describe('integrationsDiscoveryRouter', () => {
beforeEach(() => {
// Default: org access check passes
mockVerifyProjectOrgAccess.mockResolvedValue(undefined);
mockFetch.mockReset();
});

// ── Auth ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -953,4 +959,103 @@ describe('integrationsDiscoveryRouter', () => {
await expect(caller.verifyGithubToken({ token: '' })).rejects.toThrow();
});
});

// ── verifySentry ─────────────────────────────────────────────────────

describe('verifySentry', () => {
it('returns org id, name, and slug on success', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: 'org-123', name: 'My Org', slug: 'my-org' }),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
const result = await caller.verifySentry({
apiToken: 'sntrys_abc',
organizationSlug: 'my-org',
});

expect(result).toEqual({ id: 'org-123', name: 'My Org', slug: 'my-org' });
expect(mockFetch).toHaveBeenCalledWith(
'https://sentry.io/api/0/organizations/my-org/',
expect.objectContaining({
headers: { Authorization: 'Bearer sntrys_abc' },
}),
);
});

it('returns empty strings when Sentry response fields are missing', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({}),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
const result = await caller.verifySentry({
apiToken: 'sntrys_abc',
organizationSlug: 'my-org',
});

expect(result).toEqual({ id: '', name: '', slug: '' });
});

it('wraps non-ok response in BAD_REQUEST', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'bad-token', organizationSlug: 'my-org' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});

it('wraps network failure in BAD_REQUEST', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: 'my-org' }),
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});

it('URL-encodes the organization slug', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: async () => ({ id: '1', name: 'Org', slug: 'org-with-slash' }),
});

const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await caller.verifySentry({ apiToken: 'tok', organizationSlug: 'org/with/slash' });

expect(mockFetch).toHaveBeenCalledWith(
'https://sentry.io/api/0/organizations/org%2Fwith%2Fslash/',
expect.any(Object),
);
});

it('throws UNAUTHORIZED when not authenticated', async () => {
const caller = createCaller({ user: null, effectiveOrgId: null });
await expectTRPCError(
caller.verifySentry({ apiToken: 'tok', organizationSlug: 'my-org' }),
'UNAUTHORIZED',
);
});

it('rejects empty apiToken', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: '', organizationSlug: 'my-org' }),
).rejects.toThrow();
});

it('rejects empty organizationSlug', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.verifySentry({ apiToken: 'sntrys_abc', organizationSlug: '' }),
).rejects.toThrow();
});
});
});
44 changes: 44 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,40 @@ describe('projectsRouter', () => {
undefined,
);
});

it('upserts alerting integration with category alerting', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockUpsertProjectIntegration.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrations.upsert({
projectId: 'p1',
category: 'alerting',
provider: 'sentry',
config: { organizationSlug: 'my-org' },
});

expect(mockUpsertProjectIntegration).toHaveBeenCalledWith(
'p1',
'alerting',
'sentry',
{ organizationSlug: 'my-org' },
undefined,
);
});

it('rejects unknown category', async () => {
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });
await expect(
caller.integrations.upsert({
projectId: 'p1',
// @ts-expect-error testing invalid category
category: 'unknown',
provider: 'sentry',
config: {},
}),
).rejects.toThrow();
});
});

describe('delete', () => {
Expand All @@ -399,6 +433,16 @@ describe('projectsRouter', () => {

expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'pm');
});

it('deletes alerting integration', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockDeleteProjectIntegration.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrations.delete({ projectId: 'p1', category: 'alerting' });

expect(mockDeleteProjectIntegration).toHaveBeenCalledWith('p1', 'alerting');
});
});
});

Expand Down
Loading
Loading