From 2b6caae3aa3495e4b19b7476c44735a638f60135 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 09:27:46 +0000 Subject: [PATCH] fix(email): resolve gmail email server-side to fix masked credential crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cascade email verify` was crashing with a Zod `invalid_string` error because the CLI passed the masked credential value (`****xyz`) returned by `credentials.list` directly as the `email: z.string().email()` field on the `verifyGmail` mutation. Fix: replace `email: z.string().email()` with `gmailEmailCredentialId: z.number()` in the `verifyGmail` input schema, resolve the email server-side via `resolveCredentialValue()` (consistent with `verifyImap` and every other credential in this router), and remove the now-redundant client-side credential value lookup from the CLI. Also adds tests for `verifyGmail` (auth, success, schema guard, token refresh failure, IMAP failure) and `verifyImap` (auth, success, invalid port, IMAP failure) — neither procedure had test coverage before. Co-Authored-By: Claude Sonnet 4.6 --- src/api/routers/integrationsDiscovery.ts | 9 +- src/cli/dashboard/email/verify.ts | 6 +- .../api/routers/integrationsDiscovery.test.ts | 191 ++++++++++++++++++ 3 files changed, 197 insertions(+), 9 deletions(-) diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index 24873435..ee5f6327 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -426,16 +426,17 @@ export const integrationsDiscoveryRouter = router({ clientIdCredentialId: z.number(), clientSecretCredentialId: z.number(), refreshTokenCredentialId: z.number(), - email: z.string().email(), + gmailEmailCredentialId: z.number(), }), ) .mutation(async ({ ctx, input }) => { logger.debug('integrationsDiscovery.verifyGmail called', { orgId: ctx.effectiveOrgId }); - const [clientId, clientSecret, refreshToken] = await Promise.all([ + const [clientId, clientSecret, refreshToken, email] = await Promise.all([ resolveCredentialValue(input.clientIdCredentialId, ctx.effectiveOrgId), resolveCredentialValue(input.clientSecretCredentialId, ctx.effectiveOrgId), resolveCredentialValue(input.refreshTokenCredentialId, ctx.effectiveOrgId), + resolveCredentialValue(input.gmailEmailCredentialId, ctx.effectiveOrgId), ]); try { @@ -451,7 +452,7 @@ export const integrationsDiscoveryRouter = router({ port: 993, secure: true, auth: { - user: input.email, + user: email, accessToken, }, logger: false, @@ -462,7 +463,7 @@ export const integrationsDiscoveryRouter = router({ await client.connect(); await client.logout(); - return { success: true, email: input.email }; + return { success: true, email }; } catch (err) { throw new TRPCError({ code: 'BAD_REQUEST', diff --git a/src/cli/dashboard/email/verify.ts b/src/cli/dashboard/email/verify.ts index f81b41e3..1c3c28a6 100644 --- a/src/cli/dashboard/email/verify.ts +++ b/src/cli/dashboard/email/verify.ts @@ -32,15 +32,11 @@ export default class EmailVerify extends DashboardCommand { this.error('Gmail credentials not linked to project. Run "cascade email oauth" first.'); } - const gmailCreds = orgCredentials.find((c: { id: number }) => c.id === gmailEmailCredId) as - | { value: string } - | undefined; - const result = await this.client.integrationsDiscovery.verifyGmail.mutate({ clientIdCredentialId: clientIdCred.id, clientSecretCredentialId: clientSecretCred.id, refreshTokenCredentialId: refreshTokenCredId, - email: gmailCreds?.value ?? '', + gmailEmailCredentialId: gmailEmailCredId, }); if (jsonOutput) { diff --git a/tests/unit/api/routers/integrationsDiscovery.test.ts b/tests/unit/api/routers/integrationsDiscovery.test.ts index 86a8fa83..c1289c07 100644 --- a/tests/unit/api/routers/integrationsDiscovery.test.ts +++ b/tests/unit/api/routers/integrationsDiscovery.test.ts @@ -23,6 +23,30 @@ vi.mock('../../../../src/db/schema/index.js', () => ({ credentials: { id: 'id', orgId: 'org_id', value: 'value' }, })); +const { mockImapConnect, mockImapLogout, MockImapFlow, mockRefreshGmailAccessToken } = vi.hoisted( + () => { + const mockImapConnect = vi.fn(); + const mockImapLogout = vi.fn(); + const MockImapFlow = vi.fn().mockImplementation(() => ({ + connect: mockImapConnect, + logout: mockImapLogout, + })); + const mockRefreshGmailAccessToken = vi.fn(); + return { mockImapConnect, mockImapLogout, MockImapFlow, mockRefreshGmailAccessToken }; + }, +); + +vi.mock('imapflow', () => ({ + ImapFlow: MockImapFlow, +})); + +vi.mock('../../../../src/email/gmail/oauth.js', () => ({ + getGmailAuthUrl: vi.fn(), + exchangeGmailCode: vi.fn(), + getGmailUserInfo: vi.fn(), + refreshGmailAccessToken: (...args: unknown[]) => mockRefreshGmailAccessToken(...args), +})); + const mockTrelloGetMe = vi.fn(); const mockTrelloGetBoards = vi.fn(); const mockTrelloGetBoardLists = vi.fn(); @@ -98,6 +122,9 @@ describe('integrationsDiscoveryRouter', () => { beforeEach(() => { mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockImapConnect.mockResolvedValue(undefined); + mockImapLogout.mockResolvedValue(undefined); + mockRefreshGmailAccessToken.mockResolvedValue({ accessToken: 'access-token-123' }); }); // ── Auth ───────────────────────────────────────────────────────────── @@ -144,6 +171,30 @@ describe('integrationsDiscoveryRouter', () => { caller.jiraProjectDetails({ ...jiraCredsInput, projectKey: 'PROJ' }), ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); }); + + it('verifyGmail throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.verifyGmail({ + clientIdCredentialId: 10, + clientSecretCredentialId: 11, + refreshTokenCredentialId: 12, + gmailEmailCredentialId: 13, + }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('verifyImap throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect( + caller.verifyImap({ + hostCredentialId: 20, + portCredentialId: 21, + usernameCredentialId: 22, + passwordCredentialId: 23, + }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); }); // ── Credential resolution ──────────────────────────────────────────── @@ -482,4 +533,144 @@ describe('integrationsDiscoveryRouter', () => { ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); }); }); + + // ── verifyGmail ─────────────────────────────────────────────────────── + + const gmailInput = { + clientIdCredentialId: 10, + clientSecretCredentialId: 11, + refreshTokenCredentialId: 12, + gmailEmailCredentialId: 13, + }; + + describe('verifyGmail', () => { + it('resolves all four credentials server-side and returns email', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'client-id' }, + { orgId: 'org-1', value: 'client-secret' }, + { orgId: 'org-1', value: 'refresh-token' }, + { orgId: 'org-1', value: 'user@gmail.com' }, + ]); + mockRefreshGmailAccessToken.mockResolvedValue({ accessToken: 'access-token-123' }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyGmail(gmailInput); + + expect(result).toEqual({ success: true, email: 'user@gmail.com' }); + expect(mockRefreshGmailAccessToken).toHaveBeenCalledWith( + 'client-id', + 'client-secret', + 'refresh-token', + ); + }); + + it('passes resolved email as IMAP user (not a credential ID or masked value)', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'client-id' }, + { orgId: 'org-1', value: 'client-secret' }, + { orgId: 'org-1', value: 'refresh-token' }, + { orgId: 'org-1', value: 'user@gmail.com' }, + ]); + mockRefreshGmailAccessToken.mockResolvedValue({ accessToken: 'tok' }); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await caller.verifyGmail(gmailInput); + + const imapArgs = MockImapFlow.mock.calls[0][0] as { auth: { user: string } }; + expect(imapArgs.auth.user).toBe('user@gmail.com'); + }); + + it('wraps token refresh failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'client-id' }, + { orgId: 'org-1', value: 'client-secret' }, + { orgId: 'org-1', value: 'bad-refresh-token' }, + { orgId: 'org-1', value: 'user@gmail.com' }, + ]); + mockRefreshGmailAccessToken.mockRejectedValue(new Error('invalid_grant')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyGmail(gmailInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('wraps IMAP connection failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'client-id' }, + { orgId: 'org-1', value: 'client-secret' }, + { orgId: 'org-1', value: 'refresh-token' }, + { orgId: 'org-1', value: 'user@gmail.com' }, + ]); + mockRefreshGmailAccessToken.mockResolvedValue({ accessToken: 'tok' }); + mockImapConnect.mockRejectedValue(new Error('Connection refused')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyGmail(gmailInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('rejects input containing a plain email string (schema change guard)', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect( + // biome-ignore lint/suspicious/noExplicitAny: testing schema rejection of old API shape + caller.verifyGmail({ ...gmailInput, email: 'user@gmail.com' } as any), + ).rejects.toThrow(); + }); + }); + + // ── verifyImap ──────────────────────────────────────────────────────── + + const imapInput = { + hostCredentialId: 20, + portCredentialId: 21, + usernameCredentialId: 22, + passwordCredentialId: 23, + }; + + describe('verifyImap', () => { + it('resolves all four credentials server-side and returns email', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'imap.example.com' }, + { orgId: 'org-1', value: '993' }, + { orgId: 'org-1', value: 'user@example.com' }, + { orgId: 'org-1', value: 'secret' }, + ]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + const result = await caller.verifyImap(imapInput); + + expect(result).toEqual({ success: true, email: 'user@example.com' }); + }); + + it('rejects non-numeric port with BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'imap.example.com' }, + { orgId: 'org-1', value: 'not-a-port' }, + { orgId: 'org-1', value: 'user@example.com' }, + { orgId: 'org-1', value: 'secret' }, + ]); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyImap(imapInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('wraps IMAP connection failure in BAD_REQUEST', async () => { + setupDbCredentials([ + { orgId: 'org-1', value: 'imap.example.com' }, + { orgId: 'org-1', value: '993' }, + { orgId: 'org-1', value: 'user@example.com' }, + { orgId: 'org-1', value: 'wrong-password' }, + ]); + mockImapConnect.mockRejectedValue(new Error('Authentication failed')); + + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + await expect(caller.verifyImap(imapInput)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + }); });