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 } from "../session.js";
3import { logger } from "../logger.js";
4
5vi.mock("../logger.js", () => ({
6 logger: {
7 debug: vi.fn(),
8 info: vi.fn(),
9 warn: vi.fn(),
10 error: vi.fn(),
11 fatal: vi.fn(),
12 },
13}));
14
15const mockFetch = vi.fn();
16
17describe("getSession", () => {
18 beforeEach(() => {
19 vi.stubGlobal("fetch", mockFetch);
20 vi.mocked(logger.error).mockClear();
21 });
22
23 afterEach(() => {
24 vi.unstubAllGlobals();
25 mockFetch.mockReset();
26 });
27
28 it("returns unauthenticated when no cookie header provided", async () => {
29 const result = await getSession("http://localhost:3000");
30 expect(result).toEqual({ authenticated: false });
31 expect(mockFetch).not.toHaveBeenCalled();
32 });
33
34 it("returns unauthenticated when cookie header has no atbb_session", async () => {
35 const result = await getSession(
36 "http://localhost:3000",
37 "other_cookie=value"
38 );
39 expect(result).toEqual({ authenticated: false });
40 expect(mockFetch).not.toHaveBeenCalled();
41 });
42
43 it("calls AppView /api/auth/session with forwarded cookie header", async () => {
44 mockFetch.mockResolvedValueOnce({
45 ok: true,
46 json: () =>
47 Promise.resolve({
48 authenticated: true,
49 did: "did:plc:abc123",
50 handle: "alice.bsky.social",
51 }),
52 });
53
54 await getSession(
55 "http://localhost:3000",
56 "atbb_session=some-token; other=value"
57 );
58
59 expect(mockFetch).toHaveBeenCalledOnce();
60 const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
61 expect(url).toBe("http://localhost:3000/api/auth/session");
62 expect((init.headers as Record<string, string>)["Cookie"]).toBe(
63 "atbb_session=some-token; other=value"
64 );
65 });
66
67 it("returns authenticated session with did and handle on success", async () => {
68 mockFetch.mockResolvedValueOnce({
69 ok: true,
70 json: () =>
71 Promise.resolve({
72 authenticated: true,
73 did: "did:plc:abc123",
74 handle: "alice.bsky.social",
75 }),
76 });
77
78 const result = await getSession(
79 "http://localhost:3000",
80 "atbb_session=token"
81 );
82
83 expect(result).toEqual({
84 authenticated: true,
85 did: "did:plc:abc123",
86 handle: "alice.bsky.social",
87 });
88 });
89
90 it("returns unauthenticated when AppView returns 401 (expired session)", async () => {
91 mockFetch.mockResolvedValueOnce({
92 ok: false,
93 status: 401,
94 });
95
96 const result = await getSession(
97 "http://localhost:3000",
98 "atbb_session=expired"
99 );
100
101 expect(result).toEqual({ authenticated: false });
102 });
103
104 it("logs error when AppView returns unexpected non-ok status (not 401)", async () => {
105 mockFetch.mockResolvedValueOnce({
106 ok: false,
107 status: 500,
108 });
109
110 const result = await getSession(
111 "http://localhost:3000",
112 "atbb_session=token"
113 );
114
115 expect(result).toEqual({ authenticated: false });
116 expect(logger.error).toHaveBeenCalledWith(
117 expect.stringContaining("unexpected non-ok status"),
118 expect.objectContaining({ status: 500 })
119 );
120 });
121
122 it("does not log error for 401 (normal expired session)", async () => {
123 mockFetch.mockResolvedValueOnce({
124 ok: false,
125 status: 401,
126 });
127
128 await getSession("http://localhost:3000", "atbb_session=expired");
129
130 expect(logger.error).not.toHaveBeenCalled();
131 });
132
133 it("returns unauthenticated when AppView response is malformed", async () => {
134 mockFetch.mockResolvedValueOnce({
135 ok: true,
136 json: () =>
137 Promise.resolve({
138 authenticated: true,
139 // missing did and handle fields
140 }),
141 });
142
143 const result = await getSession(
144 "http://localhost:3000",
145 "atbb_session=token"
146 );
147
148 expect(result).toEqual({ authenticated: false });
149 });
150
151 it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => {
152 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
153
154 const result = await getSession(
155 "http://localhost:3000",
156 "atbb_session=token"
157 );
158
159 expect(result).toEqual({ authenticated: false });
160 expect(logger.error).toHaveBeenCalledWith(
161 expect.stringContaining("network error"),
162 expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") })
163 );
164 });
165
166 it("returns unauthenticated when AppView returns authenticated:false", async () => {
167 mockFetch.mockResolvedValueOnce({
168 ok: false,
169 status: 401,
170 json: () => Promise.resolve({ authenticated: false }),
171 });
172
173 const result = await getSession(
174 "http://localhost:3000",
175 "atbb_session=token"
176 );
177
178 expect(result).toEqual({ authenticated: false });
179 });
180});
181
182describe("getSessionWithPermissions", () => {
183 beforeEach(() => {
184 vi.stubGlobal("fetch", mockFetch);
185 vi.mocked(logger.error).mockClear();
186 });
187
188 afterEach(() => {
189 vi.unstubAllGlobals();
190 mockFetch.mockReset();
191 });
192
193 it("returns unauthenticated with empty permissions when no cookie", async () => {
194 const result = await getSessionWithPermissions("http://localhost:3000");
195 expect(result).toMatchObject({ authenticated: false });
196 expect(result.permissions.size).toBe(0);
197 });
198
199 it("returns authenticated with empty permissions when members/me returns 404", async () => {
200 mockFetch.mockResolvedValueOnce({
201 ok: true,
202 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
203 });
204 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
205
206 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
207 expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" });
208 expect(result.permissions.size).toBe(0);
209 });
210
211 it("returns permissions as Set when members/me succeeds", async () => {
212 mockFetch.mockResolvedValueOnce({
213 ok: true,
214 json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }),
215 });
216 mockFetch.mockResolvedValueOnce({
217 ok: true,
218 json: () => Promise.resolve({
219 did: "did:plc:mod",
220 handle: "mod.bsky.social",
221 role: "Moderator",
222 roleUri: "at://...",
223 permissions: [
224 "space.atbb.permission.moderatePosts",
225 "space.atbb.permission.lockTopics",
226 "space.atbb.permission.banUsers",
227 ],
228 }),
229 });
230
231 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
232 expect(result.authenticated).toBe(true);
233 expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true);
234 expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true);
235 expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true);
236 expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false);
237 });
238
239 it("returns empty permissions without crashing when members/me call throws", async () => {
240 mockFetch.mockResolvedValueOnce({
241 ok: true,
242 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
243 });
244 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
245
246 const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
247 expect(result.authenticated).toBe(true);
248 expect(result.permissions.size).toBe(0);
249 expect(logger.error).toHaveBeenCalledWith(
250 expect.stringContaining("network error"),
251 expect.any(Object)
252 );
253 });
254
255 it("does not log error when members/me returns 404 (expected for guests)", async () => {
256 mockFetch.mockResolvedValueOnce({
257 ok: true,
258 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
259 });
260 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
261
262 await getSessionWithPermissions("http://localhost:3000", "atbb_session=token");
263 expect(logger.error).not.toHaveBeenCalled();
264 });
265
266 it("forwards cookie header to members/me call", async () => {
267 mockFetch.mockResolvedValueOnce({
268 ok: true,
269 json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }),
270 });
271 mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
272
273 await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken");
274
275 expect(mockFetch).toHaveBeenCalledTimes(2);
276 const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit];
277 expect(url).toBe("http://localhost:3000/api/admin/members/me");
278 expect((init.headers as Record<string, string>)["Cookie"]).toBe("atbb_session=mytoken");
279 });
280});
281
282describe("permission helpers", () => {
283 const modSession = {
284 authenticated: true as const,
285 did: "did:plc:mod",
286 handle: "mod.bsky.social",
287 permissions: new Set([
288 "space.atbb.permission.lockTopics",
289 "space.atbb.permission.moderatePosts",
290 "space.atbb.permission.banUsers",
291 ]),
292 };
293
294 const memberSession = {
295 authenticated: true as const,
296 did: "did:plc:member",
297 handle: "member.bsky.social",
298 permissions: new Set<string>(),
299 };
300
301 const unauthSession = { authenticated: false as const, permissions: new Set<string>() };
302
303 it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true));
304 it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false));
305 it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false));
306
307 it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true));
308 it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false));
309
310 it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true));
311 it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false));
312
313 // Wildcard "*" permission — Owner role grants all permissions via the catch-all
314 const ownerSession = {
315 authenticated: true as const,
316 did: "did:plc:owner",
317 handle: "owner.bsky.social",
318 permissions: new Set(["*"]),
319 };
320
321 it("canLockTopics returns true for owner with wildcard permission", () =>
322 expect(canLockTopics(ownerSession)).toBe(true));
323 it("canModeratePosts returns true for owner with wildcard permission", () =>
324 expect(canModeratePosts(ownerSession)).toBe(true));
325 it("canBanUsers returns true for owner with wildcard permission", () =>
326 expect(canBanUsers(ownerSession)).toBe(true));
327});