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
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});