From dec17749964ea627cd2f57c485a5aefc918656b6 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Tue, 26 May 2026 00:26:41 +0530 Subject: [PATCH] fix(profile): remove OAuth token from audit/run Inngest event payload bootstrapProfile was embedding the user's live GitHub OAuth provider_token in the audit/run event data. Inngest retains event payloads in its cloud infrastructure for replay and debugging, meaning every signing user's access token was persisted in a third-party service indefinitely. The fix looks up an active GitHub App installation for the user and passes only the installation ID instead. The audit function already prefers installation tokens and will find a valid auth source from the ID. If no installation exists yet, the audit is not queued here; the install webhook handler fires its own audit/run event with the installationId once the app is installed, so no audit is missed. Adds a test suite for the bootstrap audit-queuing path verifying that installationId is used, accessToken is absent, and the event is skipped correctly when no install or when audit is already complete. Closes #204 --- src/app/actions/profile.test.ts | 161 ++++++++++++++++++++++++++++++++ src/app/actions/profile.ts | 23 ++++- 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 src/app/actions/profile.test.ts diff --git a/src/app/actions/profile.test.ts b/src/app/actions/profile.test.ts new file mode 100644 index 0000000..6453cd0 --- /dev/null +++ b/src/app/actions/profile.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * Unit tests for bootstrapProfile — specifically the audit-queuing path. + * + * Verifies that: + * - When a GitHub App installation exists for the user, audit/run is + * fired with installationId and no accessToken field. + * - When no installation exists, audit/run is not queued (the install + * webhook will fire it with the installationId when the user installs). + * - The OAuth provider_token is never included in any Inngest payload. + */ + +const mocks = vi.hoisted(() => ({ + mockGetUser: vi.fn(), + mockGetSession: vi.fn(), + mockServiceFrom: vi.fn(), + mockInngestSend: vi.fn(), +})); + +vi.mock('@/lib/supabase/server', () => ({ + getServerSupabase: () => ({ + auth: { + getUser: mocks.mockGetUser, + getSession: mocks.mockGetSession, + }, + }), +})); + +vi.mock('@/lib/supabase/service', () => ({ + getServiceSupabase: () => ({ + from: mocks.mockServiceFrom, + }), +})); + +vi.mock('@/inngest/client', () => ({ + inngest: { send: mocks.mockInngestSend }, +})); + +vi.mock('next/cache', () => ({ revalidatePath: vi.fn() })); + +import { bootstrapProfile } from './profile'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a chainable Supabase query mock that resolves to `result`. */ +function makeChain(result: unknown) { + const chain: Record = { + select: vi.fn().mockReturnThis(), + upsert: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + order: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + in: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue(result), + maybeSingle: vi.fn().mockResolvedValue(result), + }; + return chain; +} + +const BASE_USER = { + id: 'user-uuid', + identities: [ + { + provider: 'github', + id: 'gh-12345', + identity_data: { user_name: 'alice', avatar_url: null, name: 'Alice' }, + }, + ], +}; + +const BASE_PROFILE = { + id: 'user-uuid', + github_handle: 'alice', + audit_completed: false, + github_stats_synced_at: null, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('bootstrapProfile - audit queuing', () => { + beforeEach(() => { + // resetAllMocks clears the mockReturnValueOnce queue in addition to call + // history, preventing values queued in one test from leaking into the next. + vi.resetAllMocks(); + mocks.mockGetUser.mockResolvedValue({ data: { user: BASE_USER }, error: null }); + mocks.mockInngestSend.mockResolvedValue(undefined); + }); + + it('queues audit/run with installationId when an active installation exists', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce(makeChain({ data: BASE_PROFILE, error: null })) // upsert profiles + .mockReturnValueOnce(makeChain({ data: { id: 42 }, error: null })) // github_installations + .mockReturnValueOnce(makeChain({ data: null, error: null })) // maintainer/discover (fire-and-forget) + .mockReturnValueOnce(makeChain({ data: null, error: null })); // github/stats-sync + + const result = await bootstrapProfile(); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.auditQueued).toBe(true); + + type InngestCall = { name: string; data: Record }; + const auditCall = mocks.mockInngestSend.mock.calls.find( + (args: unknown[]) => (args[0] as InngestCall)?.name === 'audit/run', + ); + expect(auditCall).toBeDefined(); + + const auditPayload = auditCall?.[0] as InngestCall; + + // Must contain installationId. + expect(auditPayload.data.installationId).toBe(42); + + // Must NOT transmit an OAuth token through Inngest. + expect(auditPayload.data).not.toHaveProperty('accessToken'); + expect(JSON.stringify(auditPayload)).not.toContain('provider_token'); + }); + + it('does not queue audit/run when no active installation exists', async () => { + mocks.mockServiceFrom + .mockReturnValueOnce(makeChain({ data: BASE_PROFILE, error: null })) // upsert profiles + .mockReturnValueOnce(makeChain({ data: null, error: null })) // github_installations (none) + .mockReturnValueOnce(makeChain({ data: null, error: null })) // maintainer/discover + .mockReturnValueOnce(makeChain({ data: null, error: null })); // github/stats-sync + + const result = await bootstrapProfile(); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.auditQueued).toBe(false); + + type InngestCall = { name: string; data: Record }; + const auditCall = mocks.mockInngestSend.mock.calls.find( + (args: unknown[]) => (args[0] as InngestCall)?.name === 'audit/run', + ); + expect(auditCall).toBeUndefined(); + }); + + it('skips audit/run entirely when audit is already completed', async () => { + const completedProfile = { ...BASE_PROFILE, audit_completed: true }; + mocks.mockServiceFrom + .mockReturnValueOnce(makeChain({ data: completedProfile, error: null })) + .mockReturnValueOnce(makeChain({ data: null, error: null })) // maintainer/discover + .mockReturnValueOnce(makeChain({ data: null, error: null })); // github/stats-sync + + const result = await bootstrapProfile(); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.data.auditQueued).toBe(false); + + type InngestCall = { name: string; data: Record }; + const auditCall = mocks.mockInngestSend.mock.calls.find( + (args: unknown[]) => (args[0] as InngestCall)?.name === 'audit/run', + ); + expect(auditCall).toBeUndefined(); + }); +}); diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 7f80828..8b68371 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -69,15 +69,32 @@ export async function bootstrapProfile(): Promise> { let auditQueued = false; if (!profile.audit_completed) { - const providerToken = (await sb.auth.getSession()).data.session?.provider_token; - if (providerToken) { + // Look up an active GitHub App installation for this user. The audit + // function uses the installation token to call the GitHub API, so we + // pass only the installation ID here. OAuth tokens must never travel + // through Inngest because the event payload is retained in third-party + // infrastructure (Inngest's cloud event log) for replay and debugging. + // + // If no installation exists yet, the install webhook handler fires its + // own audit/run event with the installationId once the user installs the + // app, so nothing is lost. + const { data: install } = await service + .from('github_installations') + .select('id') + .eq('user_id', profile.id) + .is('uninstalled_at', null) + .order('installed_at', { ascending: false }) + .limit(1) + .maybeSingle(); + + if (install?.id) { await inngest.send({ name: 'audit/run', data: { userId: profile.id, githubHandle: profile.github_handle, githubId, - accessToken: providerToken, + installationId: install.id, }, }); auditQueued = true;