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 user-theme-preferences 526 lines 19 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 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});