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
at atb-52-css-token-extraction 445 lines 16 kB view raw
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});