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, vi, beforeEach, afterEach } from "vitest";
2import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles } from "../session.js";
3import type { WebSessionWithPermissions } from "../session.js";
4import { logger } from "../logger.js";
5
6vi.mock("../logger.js", () => ({
7 logger: {
8 debug: vi.fn(),
9 info: vi.fn(),
10 warn: vi.fn(),
11 error: vi.fn(),
12 fatal: vi.fn(),
13 },
14}));
15
16const mockFetch = vi.fn();
17
18describe("getSession", () => {
19 beforeEach(() => {
20 vi.stubGlobal("fetch", mockFetch);
21 vi.mocked(logger.error).mockClear();
22 });
23
24 afterEach(() => {
25 vi.unstubAllGlobals();
26 mockFetch.mockReset();
27 });
28
29 it("returns unauthenticated when no cookie header provided", async () => {
30 const result = await getSession("http://localhost:3000");
31 expect(result).toEqual({ authenticated: false });
32 expect(mockFetch).not.toHaveBeenCalled();
33 });
34
35 it("returns unauthenticated when cookie header has no atbb_session", async () => {
36 const result = await getSession(
37 "http://localhost:3000",
38 "other_cookie=value"
39 );
40 expect(result).toEqual({ authenticated: false });
41 expect(mockFetch).not.toHaveBeenCalled();
42 });
43
44 it("calls AppView /api/auth/session with forwarded cookie header", async () => {
45 mockFetch.mockResolvedValueOnce({
46 ok: true,
47 json: () =>
48 Promise.resolve({
49 authenticated: true,
50 did: "did:plc:abc123",
51 handle: "alice.bsky.social",
52 }),
53 });
54
55 await getSession(
56 "http://localhost:3000",
57 "atbb_session=some-token; other=value"
58 );
59
60 expect(mockFetch).toHaveBeenCalledOnce();
61 const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
62 expect(url).toBe("http://localhost:3000/api/auth/session");
63 expect((init.headers as Record<string, string>)["Cookie"]).toBe(
64 "atbb_session=some-token; other=value"
65 );
66 });
67
68 it("returns authenticated session with did and handle on success", async () => {
69 mockFetch.mockResolvedValueOnce({
70 ok: true,
71 json: () =>
72 Promise.resolve({
73 authenticated: true,
74 did: "did:plc:abc123",
75 handle: "alice.bsky.social",
76 }),
77 });
78
79 const result = await getSession(
80 "http://localhost:3000",
81 "atbb_session=token"
82 );
83
84 expect(result).toEqual({
85 authenticated: true,
86 did: "did:plc:abc123",
87 handle: "alice.bsky.social",
88 });
89 });
90
91 it("returns unauthenticated when AppView returns 401 (expired session)", async () => {
92 mockFetch.mockResolvedValueOnce({
93 ok: false,
94 status: 401,
95 });
96
97 const result = await getSession(
98 "http://localhost:3000",
99 "atbb_session=expired"
100 );
101
102 expect(result).toEqual({ authenticated: false });
103 });
104
105 it("logs error when AppView returns unexpected non-ok status (not 401)", async () => {
106 mockFetch.mockResolvedValueOnce({
107 ok: false,
108 status: 500,
109 });
110
111 const result = await getSession(
112 "http://localhost:3000",
113 "atbb_session=token"
114 );
115
116 expect(result).toEqual({ authenticated: false });
117 expect(logger.error).toHaveBeenCalledWith(
118 expect.stringContaining("unexpected non-ok status"),
119 expect.objectContaining({ status: 500 })
120 );
121 });
122
123 it("does not log error for 401 (normal expired session)", async () => {
124 mockFetch.mockResolvedValueOnce({
125 ok: false,
126 status: 401,
127 });
128
129 await getSession("http://localhost:3000", "atbb_session=expired");
130
131 expect(logger.error).not.toHaveBeenCalled();
132 });
133
134 it("returns unauthenticated when AppView response has missing fields", async () => {
135 mockFetch.mockResolvedValueOnce({
136 ok: true,
137 json: () =>
138 Promise.resolve({
139 authenticated: true,
140 // missing did and handle fields
141 }),
142 });
143
144 const result = await getSession(
145 "http://localhost:3000",
146 "atbb_session=token"
147 );
148
149 expect(result).toEqual({ authenticated: false });
150 });
151
152 it("returns unauthenticated when AppView returns invalid JSON", async () => {
153 // A proxy or misconfigured server might return an HTML error page on a 200 response.
154 // res.json() throws SyntaxError in that case — must be caught gracefully.
155 mockFetch.mockResolvedValueOnce({
156 ok: true,
157 json: () =>
158 Promise.reject(
159 new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON")
160 ),
161 });
162
163 const result = await getSession(
164 "http://localhost:3000",
165 "atbb_session=token"
166 );
167
168 expect(result).toEqual({ authenticated: false });
169 expect(logger.error).toHaveBeenCalledWith(
170 expect.stringContaining("invalid JSON"),
171 expect.any(Object)
172 );
173 });
174
175 it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => {
176 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
177
178 const result = await getSession(
179 "http://localhost:3000",
180 "atbb_session=token"
181 );
182
183 expect(result).toEqual({ authenticated: false });
184 expect(logger.error).toHaveBeenCalledWith(
185 expect.stringContaining("network error"),
186 expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") })
187 );
188 });
189
190 it("returns unauthenticated when fetch throws TypeError (undici 'fetch failed')", async () => {
191 // undici throws TypeError: fetch failed — not a plain Error — for network failures.
192 // This must NOT be re-thrown as a programming error.
193 mockFetch.mockRejectedValueOnce(new TypeError("fetch failed"));
194
195 const result = await getSession(
196 "http://localhost:3000",
197 "atbb_session=token"
198 );
199
200 expect(result).toEqual({ authenticated: false });
201 expect(logger.error).toHaveBeenCalledWith(
202 expect.stringContaining("network error"),
203 expect.objectContaining({ error: "fetch failed" })
204 );
205 });
206
207 it("returns unauthenticated when AppView returns authenticated:false", async () => {
208 mockFetch.mockResolvedValueOnce({
209 ok: false,
210 status: 401,
211 json: () => Promise.resolve({ authenticated: false }),
212 });
213
214 const result = await getSession(
215 "http://localhost:3000",
216 "atbb_session=token"
217 );
218
219 expect(result).toEqual({ authenticated: false });
220 });
221});
222
223describe("getSessionWithPermissions", () => {
224 beforeEach(() => {
225 vi.stubGlobal("fetch", mockFetch);
226 vi.mocked(logger.error).mockClear();
227 });
228
229 afterEach(() => {
230 vi.unstubAllGlobals();
231 mockFetch.mockReset();
232 });
233
234 it("returns unauthenticated with empty permissions when no cookie", async () => {
235 const result = await getSessionWithPermissions("http://localhost:3000");
236 expect(result).toMatchObject({ authenticated: false });
237 expect(result.permissions.size).toBe(0);
238 });
239
240 it("returns authenticated with empty permissions when members/me returns 404", async () => {
241 mockFetch.mockResolvedValueOnce({
242 ok: true,
243 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
244 });
245 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
246
247 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
248 expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" });
249 expect(result.permissions.size).toBe(0);
250 });
251
252 it("returns permissions as Set when members/me succeeds", async () => {
253 mockFetch.mockResolvedValueOnce({
254 ok: true,
255 json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }),
256 });
257 mockFetch.mockResolvedValueOnce({
258 ok: true,
259 json: () => Promise.resolve({
260 did: "did:plc:mod",
261 handle: "mod.bsky.social",
262 role: "Moderator",
263 roleUri: "at://...",
264 permissions: [
265 "space.atbb.permission.moderatePosts",
266 "space.atbb.permission.lockTopics",
267 "space.atbb.permission.banUsers",
268 ],
269 }),
270 });
271
272 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
273 expect(result.authenticated).toBe(true);
274 expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true);
275 expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true);
276 expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true);
277 expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false);
278 });
279
280 it("returns empty permissions without crashing when members/me call throws", async () => {
281 mockFetch.mockResolvedValueOnce({
282 ok: true,
283 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
284 });
285 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
286
287 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
288 expect(result.authenticated).toBe(true);
289 expect(result.permissions.size).toBe(0);
290 expect(logger.error).toHaveBeenCalledWith(
291 expect.stringContaining("network error"),
292 expect.any(Object)
293 );
294 });
295
296 it("returns empty permissions when members/me throws TypeError (undici 'fetch failed')", async () => {
297 // undici throws TypeError: fetch failed — not a plain Error — for network failures.
298 // This must NOT be re-thrown as a programming error.
299 mockFetch.mockResolvedValueOnce({
300 ok: true,
301 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
302 });
303 mockFetch.mockRejectedValueOnce(new TypeError("fetch failed"));
304
305 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
306 expect(result.authenticated).toBe(true);
307 expect(result.permissions.size).toBe(0);
308 expect(logger.error).toHaveBeenCalledWith(
309 expect.stringContaining("network error"),
310 expect.objectContaining({ error: "fetch failed", did: "did:plc:abc" })
311 );
312 });
313
314 it("returns empty permissions when members/me returns invalid JSON", async () => {
315 // A proxy might return an HTML error page even on 200. permRes.json() would throw SyntaxError.
316 mockFetch.mockResolvedValueOnce({
317 ok: true,
318 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
319 });
320 mockFetch.mockResolvedValueOnce({
321 ok: true,
322 json: () =>
323 Promise.reject(
324 new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON")
325 ),
326 });
327
328 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
329 expect(result.authenticated).toBe(true);
330 expect(result.permissions.size).toBe(0);
331 expect(logger.error).toHaveBeenCalledWith(
332 expect.stringContaining("invalid JSON"),
333 expect.any(Object)
334 );
335 });
336
337 it("does not log error when members/me returns 404 (expected for guests)", async () => {
338 mockFetch.mockResolvedValueOnce({
339 ok: true,
340 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
341 });
342 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
343
344 await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
345 expect(logger.error).not.toHaveBeenCalled();
346 });
347
348 it("forwards cookie header to members/me call", async () => {
349 mockFetch.mockResolvedValueOnce({
350 ok: true,
351 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
352 });
353 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
354
355 await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken");
356
357 expect(mockFetch).toHaveBeenCalledTimes(2);
358 const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit];
359 expect(url).toBe("http://localhost:3000/api/admin/members/me");
360 expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken");
361 });
362});
363
364describe("permission helpers", () => {
365 const modSession = {
366 authenticated: true as const,
367 did: "did:plc:mod",
368 handle: "mod.bsky.social",
369 permissions: new Set([
370 "space.atbb.permission.lockTopics",
371 "space.atbb.permission.moderatePosts",
372 "space.atbb.permission.banUsers",
373 ]),
374 };
375
376 const memberSession = {
377 authenticated: true as const,
378 did: "did:plc:member",
379 handle: "member.bsky.social",
380 permissions: new Set<string>(),
381 };
382
383 const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
384
385 it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true));
386 it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false));
387 it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false));
388
389 it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true));
390 it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false));
391
392 it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true));
393 it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false));
394
395 // Wildcard "*" permission — Owner role grants all permissions via the catch-all
396 const ownerSession = {
397 authenticated: true as const,
398 did: "did:plc:owner",
399 handle: "owner.bsky.social",
400 permissions: new Set(["*"]),
401 };
402
403 it("canLockTopics returns true for owner with wildcard permission", () =>
404 expect(canLockTopics(ownerSession)).toBe(true));
405 it("canModeratePosts returns true for owner with wildcard permission", () =>
406 expect(canModeratePosts(ownerSession)).toBe(true));
407 it("canBanUsers returns true for owner with wildcard permission", () =>
408 expect(canBanUsers(ownerSession)).toBe(true));
409
410 const makeSinglePermSessionHelper = (permission: string) => ({
411 authenticated: true as const,
412 did: "did:plc:user",
413 handle: "user.bsky.social",
414 permissions: new Set([permission]),
415 });
416
417 it("canManageMembers returns true for user with manageMembers", () =>
418 expect(canManageMembers(makeSinglePermSessionHelper("space.atbb.permission.manageMembers"))).toBe(true));
419 it("canManageMembers returns false for member with no permissions", () =>
420 expect(canManageMembers(memberSession)).toBe(false));
421 it("canManageMembers returns true for owner with wildcard", () =>
422 expect(canManageMembers(ownerSession)).toBe(true));
423
424 it("canManageCategories returns true for user with manageCategories", () =>
425 expect(canManageCategories(makeSinglePermSessionHelper("space.atbb.permission.manageCategories"))).toBe(true));
426 it("canManageCategories returns false for member with no permissions", () =>
427 expect(canManageCategories(memberSession)).toBe(false));
428 it("canManageCategories returns true for owner with wildcard", () =>
429 expect(canManageCategories(ownerSession)).toBe(true));
430
431 it("canViewModLog returns true for user with moderatePosts", () =>
432 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.moderatePosts"))).toBe(true));
433 it("canViewModLog returns true for user with banUsers", () =>
434 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.banUsers"))).toBe(true));
435 it("canViewModLog returns true for user with lockTopics", () =>
436 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.lockTopics"))).toBe(true));
437 it("canViewModLog returns false for member with no permissions", () =>
438 expect(canViewModLog(memberSession)).toBe(false));
439 it("canViewModLog returns true for owner with wildcard", () =>
440 expect(canViewModLog(ownerSession)).toBe(true));
441});
442
443describe("hasAnyAdminPermission", () => {
444 const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
445
446 const noPermSession = {
447 authenticated: true as const,
448 did: "did:plc:member",
449 handle: "member.bsky.social",
450 permissions: new Set<string>(),
451 };
452
453 const makeSinglePermSession = (permission: string) => ({
454 authenticated: true as const,
455 did: "did:plc:user",
456 handle: "user.bsky.social",
457 permissions: new Set([permission]),
458 });
459
460 it("returns false for unauthenticated session", () =>
461 expect(hasAnyAdminPermission(unauthSession)).toBe(false));
462
463 it("returns false for authenticated user with no permissions", () =>
464 expect(hasAnyAdminPermission(noPermSession)).toBe(false));
465
466 it("returns true for user with manageMembers permission", () =>
467 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageMembers"))).toBe(true));
468
469 it("returns true for user with manageCategories permission", () =>
470 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageCategories"))).toBe(true));
471
472 it("returns true for user with moderatePosts permission", () =>
473 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.moderatePosts"))).toBe(true));
474
475 it("returns true for user with banUsers permission", () =>
476 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.banUsers"))).toBe(true));
477
478 it("returns true for user with lockTopics permission", () =>
479 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.lockTopics"))).toBe(true));
480
481 it("returns true for user with wildcard permission", () =>
482 expect(hasAnyAdminPermission(makeSinglePermSession("*"))).toBe(true));
483
484 it("returns false for user with only an unrelated permission", () =>
485 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.someOtherThing"))).toBe(false));
486});
487
488describe("canManageRoles", () => {
489 it("returns false for unauthenticated session", () => {
490 const auth: WebSessionWithPermissions = {
491 authenticated: false,
492 permissions: new Set(),
493 };
494 expect(canManageRoles(auth)).toBe(false);
495 });
496
497 it("returns false when authenticated but missing manageRoles", () => {
498 const auth: WebSessionWithPermissions = {
499 authenticated: true,
500 did: "did:plc:x",
501 handle: "x.bsky.social",
502 permissions: new Set(["space.atbb.permission.manageMembers"]),
503 };
504 expect(canManageRoles(auth)).toBe(false);
505 });
506
507 it("returns true with manageRoles permission", () => {
508 const auth: WebSessionWithPermissions = {
509 authenticated: true,
510 did: "did:plc:x",
511 handle: "x.bsky.social",
512 permissions: new Set(["space.atbb.permission.manageRoles"]),
513 };
514 expect(canManageRoles(auth)).toBe(true);
515 });
516
517 it("returns true with wildcard (*) permission", () => {
518 const auth: WebSessionWithPermissions = {
519 authenticated: true,
520 did: "did:plc:x",
521 handle: "x.bsky.social",
522 permissions: new Set(["*"]),
523 };
524 expect(canManageRoles(auth)).toBe(true);
525 });
526});