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 is malformed", 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 and logs when AppView is unreachable (network error)", async () => {
153 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
154
155 const result = await getSession(
156 "http://localhost:3000",
157 "atbb_session=token"
158 );
159
160 expect(result).toEqual({ authenticated: false });
161 expect(logger.error).toHaveBeenCalledWith(
162 expect.stringContaining("network error"),
163 expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") })
164 );
165 });
166
167 it("returns unauthenticated when AppView returns authenticated:false", async () => {
168 mockFetch.mockResolvedValueOnce({
169 ok: false,
170 status: 401,
171 json: () => Promise.resolve({ authenticated: false }),
172 });
173
174 const result = await getSession(
175 "http://localhost:3000",
176 "atbb_session=token"
177 );
178
179 expect(result).toEqual({ authenticated: false });
180 });
181});
182
183describe("getSessionWithPermissions", () => {
184 beforeEach(() => {
185 vi.stubGlobal("fetch", mockFetch);
186 vi.mocked(logger.error).mockClear();
187 });
188
189 afterEach(() => {
190 vi.unstubAllGlobals();
191 mockFetch.mockReset();
192 });
193
194 it("returns unauthenticated with empty permissions when no cookie", async () => {
195 const result = await getSessionWithPermissions("http://localhost:3000");
196 expect(result).toMatchObject({ authenticated: false });
197 expect(result.permissions.size).toBe(0);
198 });
199
200 it("returns authenticated with empty permissions when members/me returns 404", async () => {
201 mockFetch.mockResolvedValueOnce({
202 ok: true,
203 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
204 });
205 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
206
207 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
208 expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" });
209 expect(result.permissions.size).toBe(0);
210 });
211
212 it("returns permissions as Set when members/me succeeds", async () => {
213 mockFetch.mockResolvedValueOnce({
214 ok: true,
215 json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }),
216 });
217 mockFetch.mockResolvedValueOnce({
218 ok: true,
219 json: () => Promise.resolve({
220 did: "did:plc:mod",
221 handle: "mod.bsky.social",
222 role: "Moderator",
223 roleUri: "at://...",
224 permissions: [
225 "space.atbb.permission.moderatePosts",
226 "space.atbb.permission.lockTopics",
227 "space.atbb.permission.banUsers",
228 ],
229 }),
230 });
231
232 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
233 expect(result.authenticated).toBe(true);
234 expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true);
235 expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true);
236 expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true);
237 expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false);
238 });
239
240 it("returns empty permissions without crashing when members/me call throws", async () => {
241 mockFetch.mockResolvedValueOnce({
242 ok: true,
243 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
244 });
245 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
246
247 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
248 expect(result.authenticated).toBe(true);
249 expect(result.permissions.size).toBe(0);
250 expect(logger.error).toHaveBeenCalledWith(
251 expect.stringContaining("network error"),
252 expect.any(Object)
253 );
254 });
255
256 it("does not log error when members/me returns 404 (expected for guests)", async () => {
257 mockFetch.mockResolvedValueOnce({
258 ok: true,
259 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
260 });
261 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
262
263 await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
264 expect(logger.error).not.toHaveBeenCalled();
265 });
266
267 it("forwards cookie header to members/me call", async () => {
268 mockFetch.mockResolvedValueOnce({
269 ok: true,
270 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
271 });
272 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
273
274 await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken");
275
276 expect(mockFetch).toHaveBeenCalledTimes(2);
277 const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit];
278 expect(url).toBe("http://localhost:3000/api/admin/members/me");
279 expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken");
280 });
281});
282
283describe("permission helpers", () => {
284 const modSession = {
285 authenticated: true as const,
286 did: "did:plc:mod",
287 handle: "mod.bsky.social",
288 permissions: new Set([
289 "space.atbb.permission.lockTopics",
290 "space.atbb.permission.moderatePosts",
291 "space.atbb.permission.banUsers",
292 ]),
293 };
294
295 const memberSession = {
296 authenticated: true as const,
297 did: "did:plc:member",
298 handle: "member.bsky.social",
299 permissions: new Set<string>(),
300 };
301
302 const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
303
304 it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true));
305 it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false));
306 it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false));
307
308 it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true));
309 it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false));
310
311 it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true));
312 it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false));
313
314 // Wildcard "*" permission — Owner role grants all permissions via the catch-all
315 const ownerSession = {
316 authenticated: true as const,
317 did: "did:plc:owner",
318 handle: "owner.bsky.social",
319 permissions: new Set(["*"]),
320 };
321
322 it("canLockTopics returns true for owner with wildcard permission", () =>
323 expect(canLockTopics(ownerSession)).toBe(true));
324 it("canModeratePosts returns true for owner with wildcard permission", () =>
325 expect(canModeratePosts(ownerSession)).toBe(true));
326 it("canBanUsers returns true for owner with wildcard permission", () =>
327 expect(canBanUsers(ownerSession)).toBe(true));
328
329 const makeSinglePermSessionHelper = (permission: string) => ({
330 authenticated: true as const,
331 did: "did:plc:user",
332 handle: "user.bsky.social",
333 permissions: new Set([permission]),
334 });
335
336 it("canManageMembers returns true for user with manageMembers", () =>
337 expect(canManageMembers(makeSinglePermSessionHelper("space.atbb.permission.manageMembers"))).toBe(true));
338 it("canManageMembers returns false for member with no permissions", () =>
339 expect(canManageMembers(memberSession)).toBe(false));
340 it("canManageMembers returns true for owner with wildcard", () =>
341 expect(canManageMembers(ownerSession)).toBe(true));
342
343 it("canManageCategories returns true for user with manageCategories", () =>
344 expect(canManageCategories(makeSinglePermSessionHelper("space.atbb.permission.manageCategories"))).toBe(true));
345 it("canManageCategories returns false for member with no permissions", () =>
346 expect(canManageCategories(memberSession)).toBe(false));
347 it("canManageCategories returns true for owner with wildcard", () =>
348 expect(canManageCategories(ownerSession)).toBe(true));
349
350 it("canViewModLog returns true for user with moderatePosts", () =>
351 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.moderatePosts"))).toBe(true));
352 it("canViewModLog returns true for user with banUsers", () =>
353 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.banUsers"))).toBe(true));
354 it("canViewModLog returns true for user with lockTopics", () =>
355 expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.lockTopics"))).toBe(true));
356 it("canViewModLog returns false for member with no permissions", () =>
357 expect(canViewModLog(memberSession)).toBe(false));
358 it("canViewModLog returns true for owner with wildcard", () =>
359 expect(canViewModLog(ownerSession)).toBe(true));
360});
361
362describe("hasAnyAdminPermission", () => {
363 const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
364
365 const noPermSession = {
366 authenticated: true as const,
367 did: "did:plc:member",
368 handle: "member.bsky.social",
369 permissions: new Set<string>(),
370 };
371
372 const makeSinglePermSession = (permission: string) => ({
373 authenticated: true as const,
374 did: "did:plc:user",
375 handle: "user.bsky.social",
376 permissions: new Set([permission]),
377 });
378
379 it("returns false for unauthenticated session", () =>
380 expect(hasAnyAdminPermission(unauthSession)).toBe(false));
381
382 it("returns false for authenticated user with no permissions", () =>
383 expect(hasAnyAdminPermission(noPermSession)).toBe(false));
384
385 it("returns true for user with manageMembers permission", () =>
386 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageMembers"))).toBe(true));
387
388 it("returns true for user with manageCategories permission", () =>
389 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageCategories"))).toBe(true));
390
391 it("returns true for user with moderatePosts permission", () =>
392 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.moderatePosts"))).toBe(true));
393
394 it("returns true for user with banUsers permission", () =>
395 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.banUsers"))).toBe(true));
396
397 it("returns true for user with lockTopics permission", () =>
398 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.lockTopics"))).toBe(true));
399
400 it("returns true for user with wildcard permission", () =>
401 expect(hasAnyAdminPermission(makeSinglePermSession("*"))).toBe(true));
402
403 it("returns false for user with only an unrelated permission", () =>
404 expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.someOtherThing"))).toBe(false));
405});
406
407describe("canManageRoles", () => {
408 it("returns false for unauthenticated session", () => {
409 const auth: WebSessionWithPermissions = {
410 authenticated: false,
411 permissions: new Set(),
412 };
413 expect(canManageRoles(auth)).toBe(false);
414 });
415
416 it("returns false when authenticated but missing manageRoles", () => {
417 const auth: WebSessionWithPermissions = {
418 authenticated: true,
419 did: "did:plc:x",
420 handle: "x.bsky.social",
421 permissions: new Set(["space.atbb.permission.manageMembers"]),
422 };
423 expect(canManageRoles(auth)).toBe(false);
424 });
425
426 it("returns true with manageRoles permission", () => {
427 const auth: WebSessionWithPermissions = {
428 authenticated: true,
429 did: "did:plc:x",
430 handle: "x.bsky.social",
431 permissions: new Set(["space.atbb.permission.manageRoles"]),
432 };
433 expect(canManageRoles(auth)).toBe(true);
434 });
435
436 it("returns true with wildcard (*) permission", () => {
437 const auth: WebSessionWithPermissions = {
438 authenticated: true,
439 did: "did:plc:x",
440 handle: "x.bsky.social",
441 permissions: new Set(["*"]),
442 };
443 expect(canManageRoles(auth)).toBe(true);
444 });
445});