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
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 2a42821cbca1aa589e8c0619ebab7129939e03dc 327 lines 11 kB view raw
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});