From 63689e50b7655e7787e02a51c737192aada625a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 09:43:37 +0000 Subject: [PATCH] Fix intermittent mock sign-in AccessDenied from installation id collisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mock GitHub ids came from a module-level counter that resets to 100000 on every server restart. On a long-lived dev DB, the SourceControlOrg upsert matches on githubLogin but creates with githubInstallationId (unique), so a new mock username could be assigned an installation id already persisted by a previous run's seed. Prisma then threw P2002, the signIn callback returned false, and the user landed on /api/auth/error?error=AccessDenied. Retrying advanced the counter and could accidentally succeed, hiding the bug. Replace the counter with ids derived deterministically from the mock username (FNV-1a, mapped into [1e9, 2e9) — inside Postgres INT4 range and clear of the fixed mock-org id), so the same username always maps to the same id across restarts. A probe inside the seeding transaction steps past the rare case where a different login already holds the derived id. Both ensureMockWorkspaceForUser and ensureStakworkMockWorkspace are covered; the stakwork variant hashes its "-stakwork"-suffixed username so the two never collide for the same user. Add unit tests for id derivation (stability, distinctness, range) and the collision probe. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R2QCg1FLW3AFd1E3BFR2TR --- src/__tests__/unit/utils/mockSetup.test.ts | 122 +++++++++++++++++++++ src/utils/mockSetup.ts | 58 +++++++++- 2 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/unit/utils/mockSetup.test.ts diff --git a/src/__tests__/unit/utils/mockSetup.test.ts b/src/__tests__/unit/utils/mockSetup.test.ts new file mode 100644 index 0000000000..b568bb3524 --- /dev/null +++ b/src/__tests__/unit/utils/mockSetup.test.ts @@ -0,0 +1,122 @@ +import type { Prisma } from "@prisma/client"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ db: {} })); + +vi.mock("@/lib/encryption", () => ({ + EncryptionService: { + getInstance: vi.fn(() => ({ + encryptField: vi.fn((field: string, value: string) => ({ + data: `encrypted_${value}`, + iv: "mock_iv", + tag: "mock_tag", + version: "v1", + encryptedAt: new Date().toISOString(), + })), + })), + }, +})); + +vi.mock("@/utils/mockSeedData", () => ({ + seedMockData: vi.fn(), + seedPublicMockWorkspace: vi.fn(), +})); + +import { deriveMockGitHubUserId, deriveMockInstallationId, resolveMockInstallationId } from "@/utils/mockSetup"; + +const INT4_MAX = 2_147_483_647; +const MOCK_ORG_INSTALLATION_ID = 999001; + +const sampleUsernames = [ + "alice", + "bob", + "mock-user", + "tom-smith", + "alice-stakwork", + "bob-stakwork", + "tom-smith-stakwork", + ...Array.from({ length: 500 }, (_, i) => `user-${i}`), +]; + +function fakeTx(orgsByInstallationId: Record): Prisma.TransactionClient { + return { + sourceControlOrg: { + findUnique: vi.fn( + async ({ where }: { where: { githubInstallationId: number } }) => + orgsByInstallationId[where.githubInstallationId] ?? null, + ), + }, + } as unknown as Prisma.TransactionClient; +} + +describe("deriveMockInstallationId", () => { + it("returns the same id for the same username on every call", () => { + // Regression guard for the original bug: the id used to come from a + // module-level counter that reset on server restart, so a persisted + // SourceControlOrg from a previous process collided (P2002) with the + // create for a new mock username. + const first = deriveMockInstallationId("alice"); + expect(deriveMockInstallationId("alice")).toBe(first); + expect(deriveMockInstallationId("alice")).toBe(first); + }); + + it("returns distinct ids for distinct usernames", () => { + const ids = sampleUsernames.map(deriveMockInstallationId); + expect(new Set(ids).size).toBe(sampleUsernames.length); + }); + + it("stays within Postgres INT4 range and clear of the fixed mock-org id", () => { + for (const id of sampleUsernames.map(deriveMockInstallationId)) { + expect(id).toBeGreaterThanOrEqual(1_000_000_000); + expect(id).toBeLessThanOrEqual(INT4_MAX); + expect(id).not.toBe(MOCK_ORG_INSTALLATION_ID); + } + }); +}); + +describe("deriveMockGitHubUserId", () => { + it("returns a stable numeric string per username", () => { + const first = deriveMockGitHubUserId("alice"); + expect(deriveMockGitHubUserId("alice")).toBe(first); + expect(first).toMatch(/^\d+$/); + }); + + it("returns distinct ids for distinct usernames", () => { + const ids = sampleUsernames.map(deriveMockGitHubUserId); + expect(new Set(ids).size).toBe(sampleUsernames.length); + }); + + it("does not collide with the installation id for the same username", () => { + for (const name of sampleUsernames) { + expect(deriveMockGitHubUserId(name)).not.toBe(String(deriveMockInstallationId(name))); + } + }); +}); + +describe("resolveMockInstallationId", () => { + it("returns the derived id when no org holds it", async () => { + const tx = fakeTx({}); + await expect(resolveMockInstallationId(tx, "alice")).resolves.toBe(deriveMockInstallationId("alice")); + }); + + it("returns the derived id when the same login already holds it (re-seed)", async () => { + const derived = deriveMockInstallationId("alice"); + const tx = fakeTx({ [derived]: { githubLogin: "alice" } }); + await expect(resolveMockInstallationId(tx, "alice")).resolves.toBe(derived); + }); + + it("probes past an id held by a different login", async () => { + const derived = deriveMockInstallationId("alice"); + const tx = fakeTx({ [derived]: { githubLogin: "someone-else" } }); + await expect(resolveMockInstallationId(tx, "alice")).resolves.toBe(derived + 1); + }); + + it("keeps probing until it finds a free id", async () => { + const derived = deriveMockInstallationId("alice"); + const tx = fakeTx({ + [derived]: { githubLogin: "someone-else" }, + [derived + 1]: { githubLogin: "another-login" }, + }); + await expect(resolveMockInstallationId(tx, "alice")).resolves.toBe(derived + 2); + }); +}); diff --git a/src/utils/mockSetup.ts b/src/utils/mockSetup.ts index cac1a1508b..09975a2d42 100644 --- a/src/utils/mockSetup.ts +++ b/src/utils/mockSetup.ts @@ -6,6 +6,7 @@ import { MilestoneStatus, PodState, PoolState, + Prisma, RepositoryStatus, SourceControlOrgType, SwarmStatus, @@ -13,8 +14,53 @@ import { import { seedMockData, seedPublicMockWorkspace } from "./mockSeedData"; import { slugify } from "./slugify"; -// Mock GitHub user ID counter (starts high to avoid conflicts) -let mockGitHubIdCounter = 100000; +// Mock GitHub ids must be stable across server restarts: SourceControlOrg is +// upserted by githubLogin but created with githubInstallationId, which is +// unique in the DB. An id from an in-memory counter changes between runs and +// can collide with a row persisted by a previous process, so sign-in for a +// new mock username fails with P2002 (surfaced as NextAuth AccessDenied). +// Ids are therefore derived from the mock username, mapped into [1e9, 2e9): +// inside Postgres INT4 range and clear of real installation ids and the +// fixed MOCK_ORG_INSTALLATION_ID. +const MOCK_ID_RANGE_START = 1_000_000_000; +const MOCK_ID_RANGE_SIZE = 1_000_000_000; + +function fnv1aHash(seed: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash; +} + +export function deriveMockInstallationId(githubLogin: string): number { + return MOCK_ID_RANGE_START + (fnv1aHash(`installation:${githubLogin}`) % MOCK_ID_RANGE_SIZE); +} + +export function deriveMockGitHubUserId(githubLogin: string): string { + return String(MOCK_ID_RANGE_START + (fnv1aHash(`user:${githubLogin}`) % MOCK_ID_RANGE_SIZE)); +} + +/** + * Returns an installation id that is safe to create for `githubLogin`: the + * derived id, or the next free one when a different login already holds it + * (a hash collision between two mock usernames — rare, but it would + * reproduce the exact P2002 sign-in failure the derived ids exist to + * prevent). Idempotent for a login whose org row already exists. + */ +export async function resolveMockInstallationId(tx: Prisma.TransactionClient, githubLogin: string): Promise { + let candidate = deriveMockInstallationId(githubLogin); + for (let attempt = 0; attempt < 1000; attempt++) { + const holder = await tx.sourceControlOrg.findUnique({ + where: { githubInstallationId: candidate }, + select: { githubLogin: true }, + }); + if (!holder || holder.githubLogin === githubLogin) return candidate; + candidate = candidate + 1 < MOCK_ID_RANGE_START + MOCK_ID_RANGE_SIZE ? candidate + 1 : MOCK_ID_RANGE_START; + } + throw new Error(`Unable to allocate a mock installation id for ${githubLogin}`); +} /** * Ensures a mock workspace and a completed swarm exist for a given user. @@ -50,8 +96,7 @@ export async function ensureMockWorkspaceForUser( const mockGitHubUsername = user?.name?.toLowerCase().replace(/\s+/g, "-") || user?.email?.split("@")[0] || "mock-user"; - const mockGitHubUserId = String(mockGitHubIdCounter++); - const mockInstallationId = mockGitHubIdCounter++; + const mockGitHubUserId = deriveMockGitHubUserId(mockGitHubUsername); // Create encrypted tokens for mock (optional - gracefully handle if encryption not available) let encryptedPoolApiKey: string | null = null; @@ -102,6 +147,7 @@ export async function ensureMockWorkspaceForUser( }); // 2. Create SourceControlOrg (represents the GitHub org/user that has the app installed) + const mockInstallationId = await resolveMockInstallationId(tx, mockGitHubUsername); const sourceControlOrg = await tx.sourceControlOrg.upsert({ where: { githubLogin: mockGitHubUsername }, create: { @@ -249,8 +295,7 @@ export async function ensureStakworkMockWorkspace( // Generate mock GitHub username from user's name or email (stakwork-specific) const mockGitHubUsername = `${user?.name?.toLowerCase().replace(/\s+/g, "-") || user?.email?.split("@")[0] || "mock-user"}-stakwork`; - const mockGitHubUserId = String(mockGitHubIdCounter++); - const mockInstallationId = mockGitHubIdCounter++; + const mockGitHubUserId = deriveMockGitHubUserId(mockGitHubUsername); // Create encrypted tokens for mock (optional - gracefully handle if encryption not available) let encryptedPoolApiKey: string | null = null; @@ -297,6 +342,7 @@ export async function ensureStakworkMockWorkspace( }); // 2. Create SourceControlOrg (represents the GitHub org/user that has the app installed) + const mockInstallationId = await resolveMockInstallationId(tx, mockGitHubUsername); const sourceControlOrg = await tx.sourceControlOrg.upsert({ where: { githubLogin: mockGitHubUsername }, create: {