import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Hono } from "hono"; import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; import { eq } from "drizzle-orm"; import { users } from "@atbb/db"; const TEST_DID = "did:plc:test-oauth"; const TEST_HANDLE = "test-oauth.test.bsky.social"; // Mock createMembershipForUser at module level BEFORE importing routes let mockCreateMembership: ReturnType; vi.mock("../../lib/membership.js", () => ({ createMembershipForUser: vi.fn((...args) => mockCreateMembership(...args)), })); // Mock Agent to avoid real PDS calls. mockGetProfile is assigned in setupOAuthMocks // so individual tests can override it per-call. let mockGetProfile: ReturnType; vi.mock("@atproto/api", () => ({ Agent: vi.fn((session: any) => ({ did: session?.did, getProfile: vi.fn((...args: any[]) => mockGetProfile(...args)), })), })); // Import routes AFTER mocking const { createAuthRoutes } = await import("../auth.js"); function setupOAuthMocks(ctx: TestContext) { (ctx.oauthClient.callback as any) = vi.fn(async () => ({ session: { did: TEST_DID, sub: TEST_DID, iss: "https://bsky.social", aud: "http://localhost:3001", exp: Math.floor(Date.now() / 1000) + 3600, iat: Math.floor(Date.now() / 1000), scope: "atproto include:space.atbb.authFull rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", server: {} as any, sessionGetter: {} as any, dpopFetch: {} as any, serverMetadata: {} as any, }, state: "test-state", })); (ctx.oauthStateStore.get as any) = vi.fn(async () => ({ iss: "https://bsky.social", pkceVerifier: "test-verifier", dpopKey: undefined, })); ctx.cookieSessionStore.set = vi.fn(async () => {}); mockGetProfile = vi.fn().mockResolvedValue({ data: { handle: TEST_HANDLE, displayName: "Test User" }, }); } describe("OAuth callback - membership creation error handling", () => { let ctx: TestContext; let app: Hono; beforeEach(async () => { ctx = await createTestContext(); app = new Hono().route("/api/auth", createAuthRoutes(ctx)); // Reset mocks vi.clearAllMocks(); setupOAuthMocks(ctx); }); afterEach(async () => { await ctx.cleanup(); }); it("allows login even when membership creation fails (PDS unreachable)", async () => { // Arrange: Mock membership helper to throw (simulating PDS error) mockCreateMembership = vi.fn().mockRejectedValue(new Error("PDS unreachable")); // Act: Call OAuth callback endpoint const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); // Assert: CRITICAL - Login must succeed despite membership creation failure expect(res.status).toBe(302); // Redirect on success expect(res.headers.get("Location")).toBe("/"); // Redirects to homepage // Assert: Session cookie was set (login completed successfully) const setCookieHeader = res.headers.get("Set-Cookie"); expect(setCookieHeader).toBeDefined(); expect(setCookieHeader).toContain("atbb_session="); // Assert: Membership creation was attempted expect(mockCreateMembership).toHaveBeenCalledWith( expect.anything(), // ctx expect.anything(), // agent TEST_DID ); }); it("allows login when membership creation throws database error", async () => { // Arrange: Mock membership helper to throw database error mockCreateMembership = vi .fn() .mockRejectedValue(new Error("Connection pool exhausted")); // Act const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); // Assert: Login still succeeds expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/"); expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); }); it("completes login when membership already exists (no duplicate)", async () => { // Arrange: Mock membership helper to return early (duplicate detected) mockCreateMembership = vi.fn().mockResolvedValue({ created: false, // Membership already exists }); // Act const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); // Assert: Login succeeds expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/"); expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); // Assert: Membership creation was called but returned early expect(mockCreateMembership).toHaveBeenCalled(); }); it("completes login when membership creation succeeds", async () => { // Arrange: Mock membership helper to succeed mockCreateMembership = vi.fn().mockResolvedValue({ created: true, uri: `at://${TEST_DID}/space.atbb.membership/test123`, cid: "bafytest123", }); // Act const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); // Assert: Login succeeds expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/"); expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); // Assert: Membership was created expect(mockCreateMembership).toHaveBeenCalled(); }); }); describe("OAuth callback - user handle persistence", () => { let ctx: TestContext; let app: Hono; beforeEach(async () => { ctx = await createTestContext(); app = new Hono().route("/api/auth", createAuthRoutes(ctx)); vi.clearAllMocks(); setupOAuthMocks(ctx); mockCreateMembership = vi.fn().mockResolvedValue({ created: false }); }); afterEach(async () => { await ctx.cleanup(); }); it("inserts user row with handle when user does not yet exist in DB", async () => { const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(302); const [user] = await ctx.db .select() .from(users) .where(eq(users.did, TEST_DID)); expect(user).toBeDefined(); expect(user.handle).toBe(TEST_HANDLE); expect(ctx.cookieSessionStore.set).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ handle: TEST_HANDLE }) ); }); it("updates existing user handle when row was created by firehose with null handle", async () => { await ctx.db.insert(users).values({ did: TEST_DID, handle: null, indexedAt: new Date(), }); const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(302); const allUsers = await ctx.db .select() .from(users) .where(eq(users.did, TEST_DID)); expect(allUsers).toHaveLength(1); expect(allUsers[0].handle).toBe(TEST_HANDLE); }); it("inserts null handle when getProfile returns no handle (suspended/migrating account)", async () => { mockGetProfile = vi.fn().mockResolvedValue({ data: { handle: undefined, displayName: "Test User" }, }); const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/"); const [user] = await ctx.db .select() .from(users) .where(eq(users.did, TEST_DID)); expect(user).toBeDefined(); expect(user.handle).toBeNull(); }); it("preserves existing handle when getProfile returns no handle", async () => { await ctx.db.insert(users).values({ did: TEST_DID, handle: "alice.bsky.social", indexedAt: new Date(), }); mockGetProfile = vi.fn().mockResolvedValue({ data: { handle: undefined, displayName: "Test User" }, }); const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(302); const [user] = await ctx.db .select() .from(users) .where(eq(users.did, TEST_DID)); expect(user.handle).toBe("alice.bsky.social"); }); it("login still succeeds if user handle DB upsert fails", async () => { const loggerWarnSpy = vi.spyOn(ctx.logger, "warn"); vi.spyOn(ctx.db, "insert").mockImplementationOnce(() => { throw new Error("DB connection lost"); }); const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(302); expect(res.headers.get("Location")).toBe("/"); expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); expect(ctx.db.insert).toHaveBeenCalled(); // Confirms the upsert path was reached before failing expect(loggerWarnSpy).toHaveBeenCalledWith( "Failed to persist user handle during login", expect.objectContaining({ did: TEST_DID, error: "DB connection lost" }) ); // No manual restore needed — beforeEach creates a fresh ctx, so this spy is abandoned. }); it("returns 500 when upsert throws a TypeError (programming error re-thrown)", async () => { vi.spyOn(ctx.db, "insert").mockImplementationOnce(() => { throw new TypeError("Cannot read properties of undefined"); }); const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); expect(res.status).toBe(500); }); });