WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 282 lines 9.3 kB view raw
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 2import { Hono } from "hono"; 3import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4import { eq } from "drizzle-orm"; 5import { users } from "@atbb/db"; 6 7const TEST_DID = "did:plc:test-oauth"; 8const TEST_HANDLE = "test-oauth.test.bsky.social"; 9 10// Mock createMembershipForUser at module level BEFORE importing routes 11let mockCreateMembership: ReturnType<typeof vi.fn>; 12 13vi.mock("../../lib/membership.js", () => ({ 14 createMembershipForUser: vi.fn((...args) => mockCreateMembership(...args)), 15})); 16 17// Mock Agent to avoid real PDS calls. mockGetProfile is assigned in setupOAuthMocks 18// so individual tests can override it per-call. 19let mockGetProfile: ReturnType<typeof vi.fn>; 20 21vi.mock("@atproto/api", () => ({ 22 Agent: vi.fn((session: any) => ({ 23 did: session?.did, 24 getProfile: vi.fn((...args: any[]) => mockGetProfile(...args)), 25 })), 26})); 27 28// Import routes AFTER mocking 29const { createAuthRoutes } = await import("../auth.js"); 30 31function setupOAuthMocks(ctx: TestContext) { 32 (ctx.oauthClient.callback as any) = vi.fn(async () => ({ 33 session: { 34 did: TEST_DID, 35 sub: TEST_DID, 36 iss: "https://bsky.social", 37 aud: "http://localhost:3001", 38 exp: Math.floor(Date.now() / 1000) + 3600, 39 iat: Math.floor(Date.now() / 1000), 40 scope: "atproto include:space.atbb.authFull rpc:app.bsky.actor.getProfile?aud=did:web:api.bsky.app%23bsky_appview", 41 server: {} as any, 42 sessionGetter: {} as any, 43 dpopFetch: {} as any, 44 serverMetadata: {} as any, 45 }, 46 state: "test-state", 47 })); 48 49 (ctx.oauthStateStore.get as any) = vi.fn(async () => ({ 50 iss: "https://bsky.social", 51 pkceVerifier: "test-verifier", 52 dpopKey: undefined, 53 })); 54 55 ctx.cookieSessionStore.set = vi.fn(async () => {}); 56 57 mockGetProfile = vi.fn().mockResolvedValue({ 58 data: { handle: TEST_HANDLE, displayName: "Test User" }, 59 }); 60} 61 62describe("OAuth callback - membership creation error handling", () => { 63 let ctx: TestContext; 64 let app: Hono; 65 66 beforeEach(async () => { 67 ctx = await createTestContext(); 68 app = new Hono().route("/api/auth", createAuthRoutes(ctx)); 69 70 // Reset mocks 71 vi.clearAllMocks(); 72 73 setupOAuthMocks(ctx); 74 }); 75 76 afterEach(async () => { 77 await ctx.cleanup(); 78 }); 79 80 it("allows login even when membership creation fails (PDS unreachable)", async () => { 81 // Arrange: Mock membership helper to throw (simulating PDS error) 82 mockCreateMembership = vi.fn().mockRejectedValue(new Error("PDS unreachable")); 83 84 // Act: Call OAuth callback endpoint 85 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 86 87 // Assert: CRITICAL - Login must succeed despite membership creation failure 88 expect(res.status).toBe(302); // Redirect on success 89 expect(res.headers.get("Location")).toBe("/"); // Redirects to homepage 90 91 // Assert: Session cookie was set (login completed successfully) 92 const setCookieHeader = res.headers.get("Set-Cookie"); 93 expect(setCookieHeader).toBeDefined(); 94 expect(setCookieHeader).toContain("atbb_session="); 95 96 // Assert: Membership creation was attempted 97 expect(mockCreateMembership).toHaveBeenCalledWith( 98 expect.anything(), // ctx 99 expect.anything(), // agent 100 TEST_DID 101 ); 102 }); 103 104 it("allows login when membership creation throws database error", async () => { 105 // Arrange: Mock membership helper to throw database error 106 mockCreateMembership = vi 107 .fn() 108 .mockRejectedValue(new Error("Connection pool exhausted")); 109 110 // Act 111 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 112 113 // Assert: Login still succeeds 114 expect(res.status).toBe(302); 115 expect(res.headers.get("Location")).toBe("/"); 116 expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); 117 }); 118 119 it("completes login when membership already exists (no duplicate)", async () => { 120 // Arrange: Mock membership helper to return early (duplicate detected) 121 mockCreateMembership = vi.fn().mockResolvedValue({ 122 created: false, // Membership already exists 123 }); 124 125 // Act 126 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 127 128 // Assert: Login succeeds 129 expect(res.status).toBe(302); 130 expect(res.headers.get("Location")).toBe("/"); 131 expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); 132 133 // Assert: Membership creation was called but returned early 134 expect(mockCreateMembership).toHaveBeenCalled(); 135 }); 136 137 it("completes login when membership creation succeeds", async () => { 138 // Arrange: Mock membership helper to succeed 139 mockCreateMembership = vi.fn().mockResolvedValue({ 140 created: true, 141 uri: `at://${TEST_DID}/space.atbb.membership/test123`, 142 cid: "bafytest123", 143 }); 144 145 // Act 146 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 147 148 // Assert: Login succeeds 149 expect(res.status).toBe(302); 150 expect(res.headers.get("Location")).toBe("/"); 151 expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); 152 153 // Assert: Membership was created 154 expect(mockCreateMembership).toHaveBeenCalled(); 155 }); 156}); 157 158describe("OAuth callback - user handle persistence", () => { 159 let ctx: TestContext; 160 let app: Hono; 161 162 beforeEach(async () => { 163 ctx = await createTestContext(); 164 app = new Hono().route("/api/auth", createAuthRoutes(ctx)); 165 vi.clearAllMocks(); 166 167 setupOAuthMocks(ctx); 168 mockCreateMembership = vi.fn().mockResolvedValue({ created: false }); 169 }); 170 171 afterEach(async () => { 172 await ctx.cleanup(); 173 }); 174 175 it("inserts user row with handle when user does not yet exist in DB", async () => { 176 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 177 expect(res.status).toBe(302); 178 179 const [user] = await ctx.db 180 .select() 181 .from(users) 182 .where(eq(users.did, TEST_DID)); 183 184 expect(user).toBeDefined(); 185 expect(user.handle).toBe(TEST_HANDLE); 186 187 expect(ctx.cookieSessionStore.set).toHaveBeenCalledWith( 188 expect.any(String), 189 expect.objectContaining({ handle: TEST_HANDLE }) 190 ); 191 }); 192 193 it("updates existing user handle when row was created by firehose with null handle", async () => { 194 await ctx.db.insert(users).values({ 195 did: TEST_DID, 196 handle: null, 197 indexedAt: new Date(), 198 }); 199 200 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 201 expect(res.status).toBe(302); 202 203 const allUsers = await ctx.db 204 .select() 205 .from(users) 206 .where(eq(users.did, TEST_DID)); 207 208 expect(allUsers).toHaveLength(1); 209 expect(allUsers[0].handle).toBe(TEST_HANDLE); 210 }); 211 212 it("inserts null handle when getProfile returns no handle (suspended/migrating account)", async () => { 213 mockGetProfile = vi.fn().mockResolvedValue({ 214 data: { handle: undefined, displayName: "Test User" }, 215 }); 216 217 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 218 expect(res.status).toBe(302); 219 expect(res.headers.get("Location")).toBe("/"); 220 221 const [user] = await ctx.db 222 .select() 223 .from(users) 224 .where(eq(users.did, TEST_DID)); 225 226 expect(user).toBeDefined(); 227 expect(user.handle).toBeNull(); 228 }); 229 230 it("preserves existing handle when getProfile returns no handle", async () => { 231 await ctx.db.insert(users).values({ 232 did: TEST_DID, 233 handle: "alice.bsky.social", 234 indexedAt: new Date(), 235 }); 236 237 mockGetProfile = vi.fn().mockResolvedValue({ 238 data: { handle: undefined, displayName: "Test User" }, 239 }); 240 241 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 242 expect(res.status).toBe(302); 243 244 const [user] = await ctx.db 245 .select() 246 .from(users) 247 .where(eq(users.did, TEST_DID)); 248 249 expect(user.handle).toBe("alice.bsky.social"); 250 }); 251 252 it("login still succeeds if user handle DB upsert fails", async () => { 253 const loggerWarnSpy = vi.spyOn(ctx.logger, "warn"); 254 255 vi.spyOn(ctx.db, "insert").mockImplementationOnce(() => { 256 throw new Error("DB connection lost"); 257 }); 258 259 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 260 261 expect(res.status).toBe(302); 262 expect(res.headers.get("Location")).toBe("/"); 263 expect(res.headers.get("Set-Cookie")).toContain("atbb_session="); 264 expect(ctx.db.insert).toHaveBeenCalled(); // Confirms the upsert path was reached before failing 265 266 expect(loggerWarnSpy).toHaveBeenCalledWith( 267 "Failed to persist user handle during login", 268 expect.objectContaining({ did: TEST_DID, error: "DB connection lost" }) 269 ); 270 // No manual restore needed — beforeEach creates a fresh ctx, so this spy is abandoned. 271 }); 272 273 it("returns 500 when upsert throws a TypeError (programming error re-thrown)", async () => { 274 vi.spyOn(ctx.db, "insert").mockImplementationOnce(() => { 275 throw new TypeError("Cannot read properties of undefined"); 276 }); 277 278 const res = await app.request("/api/auth/callback?code=test-code&state=test-state"); 279 280 expect(res.status).toBe(500); 281 }); 282});