import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { getSession, getSessionWithPermissions, canLockTopics, canModeratePosts, canBanUsers, hasAnyAdminPermission, canManageMembers, canManageCategories, canViewModLog, canManageRoles } from "../session.js"; import type { WebSessionWithPermissions } from "../session.js"; import { logger } from "../logger.js"; vi.mock("../logger.js", () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn(), }, })); const mockFetch = vi.fn(); describe("getSession", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.mocked(logger.error).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); it("returns unauthenticated when no cookie header provided", async () => { const result = await getSession("http://localhost:3000"); expect(result).toEqual({ authenticated: false }); expect(mockFetch).not.toHaveBeenCalled(); }); it("returns unauthenticated when cookie header has no atbb_session", async () => { const result = await getSession( "http://localhost:3000", "other_cookie=value" ); expect(result).toEqual({ authenticated: false }); expect(mockFetch).not.toHaveBeenCalled(); }); it("calls AppView /api/auth/session with forwarded cookie header", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc123", handle: "alice.bsky.social", }), }); await getSession( "http://localhost:3000", "atbb_session=some-token; other=value" ); expect(mockFetch).toHaveBeenCalledOnce(); const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; expect(url).toBe("http://localhost:3000/api/auth/session"); expect((init.headers as Record)["Cookie"]).toBe( "atbb_session=some-token; other=value" ); }); it("returns authenticated session with did and handle on success", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc123", handle: "alice.bsky.social", }), }); const result = await getSession( "http://localhost:3000", "atbb_session=token" ); expect(result).toEqual({ authenticated: true, did: "did:plc:abc123", handle: "alice.bsky.social", }); }); it("returns unauthenticated when AppView returns 401 (expired session)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, }); const result = await getSession( "http://localhost:3000", "atbb_session=expired" ); expect(result).toEqual({ authenticated: false }); }); it("logs error when AppView returns unexpected non-ok status (not 401)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, }); const result = await getSession( "http://localhost:3000", "atbb_session=token" ); expect(result).toEqual({ authenticated: false }); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("unexpected non-ok status"), expect.objectContaining({ status: 500 }) ); }); it("does not log error for 401 (normal expired session)", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, }); await getSession("http://localhost:3000", "atbb_session=expired"); expect(logger.error).not.toHaveBeenCalled(); }); it("returns unauthenticated when AppView response is malformed", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, // missing did and handle fields }), }); const result = await getSession( "http://localhost:3000", "atbb_session=token" ); expect(result).toEqual({ authenticated: false }); }); it("returns unauthenticated and logs when AppView is unreachable (network error)", async () => { mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); const result = await getSession( "http://localhost:3000", "atbb_session=token" ); expect(result).toEqual({ authenticated: false }); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("network error"), expect.objectContaining({ error: expect.stringContaining("ECONNREFUSED") }) ); }); it("returns unauthenticated when AppView returns authenticated:false", async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ authenticated: false }), }); const result = await getSession( "http://localhost:3000", "atbb_session=token" ); expect(result).toEqual({ authenticated: false }); }); }); describe("getSessionWithPermissions", () => { beforeEach(() => { vi.stubGlobal("fetch", mockFetch); vi.mocked(logger.error).mockClear(); }); afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); it("returns unauthenticated with empty permissions when no cookie", async () => { const result = await getSessionWithPermissions("http://localhost:3000"); expect(result).toMatchObject({ authenticated: false }); expect(result.permissions.size).toBe(0); }); it("returns authenticated with empty permissions when members/me returns 404", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), }); mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); expect(result).toMatchObject({ authenticated: true, did: "did:plc:abc" }); expect(result.permissions.size).toBe(0); }); it("returns permissions as Set when members/me succeeds", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:mod", handle: "mod.bsky.social" }), }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ did: "did:plc:mod", handle: "mod.bsky.social", role: "Moderator", roleUri: "at://...", permissions: [ "space.atbb.permission.moderatePosts", "space.atbb.permission.lockTopics", "space.atbb.permission.banUsers", ], }), }); const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); expect(result.authenticated).toBe(true); expect(result.permissions.has("space.atbb.permission.moderatePosts")).toBe(true); expect(result.permissions.has("space.atbb.permission.lockTopics")).toBe(true); expect(result.permissions.has("space.atbb.permission.banUsers")).toBe(true); expect(result.permissions.has("space.atbb.permission.manageCategories")).toBe(false); }); it("returns empty permissions without crashing when members/me call throws", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), }); mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED")); const result = await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); expect(result.authenticated).toBe(true); expect(result.permissions.size).toBe(0); expect(logger.error).toHaveBeenCalledWith( expect.stringContaining("network error"), expect.any(Object) ); }); it("does not log error when members/me returns 404 (expected for guests)", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), }); mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); await getSessionWithPermissions("http://localhost:3000", "atbb_session=token"); expect(logger.error).not.toHaveBeenCalled(); }); it("forwards cookie header to members/me call", async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authenticated: true, did: "did:plc:abc", handle: "alice.bsky.social" }), }); mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); await getSessionWithPermissions("http://localhost:3000", "atbb_session=mytoken"); expect(mockFetch).toHaveBeenCalledTimes(2); const [url, init] = mockFetch.mock.calls[1] as [string, RequestInit]; expect(url).toBe("http://localhost:3000/api/admin/members/me"); expect((init.headers as Record)["Cookie"]).toBe("atbb_session=mytoken"); }); }); describe("permission helpers", () => { const modSession = { authenticated: true as const, did: "did:plc:mod", handle: "mod.bsky.social", permissions: new Set([ "space.atbb.permission.lockTopics", "space.atbb.permission.moderatePosts", "space.atbb.permission.banUsers", ]), }; const memberSession = { authenticated: true as const, did: "did:plc:member", handle: "member.bsky.social", permissions: new Set(), }; const unauthSession = { authenticated: false as const, permissions: new Set() }; it("canLockTopics returns true for mod", () => expect(canLockTopics(modSession)).toBe(true)); it("canLockTopics returns false for member", () => expect(canLockTopics(memberSession)).toBe(false)); it("canLockTopics returns false for unauthenticated", () => expect(canLockTopics(unauthSession)).toBe(false)); it("canModeratePosts returns true for mod", () => expect(canModeratePosts(modSession)).toBe(true)); it("canModeratePosts returns false for member", () => expect(canModeratePosts(memberSession)).toBe(false)); it("canBanUsers returns true for mod", () => expect(canBanUsers(modSession)).toBe(true)); it("canBanUsers returns false for member", () => expect(canBanUsers(memberSession)).toBe(false)); // Wildcard "*" permission — Owner role grants all permissions via the catch-all const ownerSession = { authenticated: true as const, did: "did:plc:owner", handle: "owner.bsky.social", permissions: new Set(["*"]), }; it("canLockTopics returns true for owner with wildcard permission", () => expect(canLockTopics(ownerSession)).toBe(true)); it("canModeratePosts returns true for owner with wildcard permission", () => expect(canModeratePosts(ownerSession)).toBe(true)); it("canBanUsers returns true for owner with wildcard permission", () => expect(canBanUsers(ownerSession)).toBe(true)); const makeSinglePermSessionHelper = (permission: string) => ({ authenticated: true as const, did: "did:plc:user", handle: "user.bsky.social", permissions: new Set([permission]), }); it("canManageMembers returns true for user with manageMembers", () => expect(canManageMembers(makeSinglePermSessionHelper("space.atbb.permission.manageMembers"))).toBe(true)); it("canManageMembers returns false for member with no permissions", () => expect(canManageMembers(memberSession)).toBe(false)); it("canManageMembers returns true for owner with wildcard", () => expect(canManageMembers(ownerSession)).toBe(true)); it("canManageCategories returns true for user with manageCategories", () => expect(canManageCategories(makeSinglePermSessionHelper("space.atbb.permission.manageCategories"))).toBe(true)); it("canManageCategories returns false for member with no permissions", () => expect(canManageCategories(memberSession)).toBe(false)); it("canManageCategories returns true for owner with wildcard", () => expect(canManageCategories(ownerSession)).toBe(true)); it("canViewModLog returns true for user with moderatePosts", () => expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.moderatePosts"))).toBe(true)); it("canViewModLog returns true for user with banUsers", () => expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.banUsers"))).toBe(true)); it("canViewModLog returns true for user with lockTopics", () => expect(canViewModLog(makeSinglePermSessionHelper("space.atbb.permission.lockTopics"))).toBe(true)); it("canViewModLog returns false for member with no permissions", () => expect(canViewModLog(memberSession)).toBe(false)); it("canViewModLog returns true for owner with wildcard", () => expect(canViewModLog(ownerSession)).toBe(true)); }); describe("hasAnyAdminPermission", () => { const unauthSession = { authenticated: false as const, permissions: new Set() }; const noPermSession = { authenticated: true as const, did: "did:plc:member", handle: "member.bsky.social", permissions: new Set(), }; const makeSinglePermSession = (permission: string) => ({ authenticated: true as const, did: "did:plc:user", handle: "user.bsky.social", permissions: new Set([permission]), }); it("returns false for unauthenticated session", () => expect(hasAnyAdminPermission(unauthSession)).toBe(false)); it("returns false for authenticated user with no permissions", () => expect(hasAnyAdminPermission(noPermSession)).toBe(false)); it("returns true for user with manageMembers permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageMembers"))).toBe(true)); it("returns true for user with manageCategories permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.manageCategories"))).toBe(true)); it("returns true for user with moderatePosts permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.moderatePosts"))).toBe(true)); it("returns true for user with banUsers permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.banUsers"))).toBe(true)); it("returns true for user with lockTopics permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.lockTopics"))).toBe(true)); it("returns true for user with wildcard permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("*"))).toBe(true)); it("returns false for user with only an unrelated permission", () => expect(hasAnyAdminPermission(makeSinglePermSession("space.atbb.permission.someOtherThing"))).toBe(false)); }); describe("canManageRoles", () => { it("returns false for unauthenticated session", () => { const auth: WebSessionWithPermissions = { authenticated: false, permissions: new Set(), }; expect(canManageRoles(auth)).toBe(false); }); it("returns false when authenticated but missing manageRoles", () => { const auth: WebSessionWithPermissions = { authenticated: true, did: "did:plc:x", handle: "x.bsky.social", permissions: new Set(["space.atbb.permission.manageMembers"]), }; expect(canManageRoles(auth)).toBe(false); }); it("returns true with manageRoles permission", () => { const auth: WebSessionWithPermissions = { authenticated: true, did: "did:plc:x", handle: "x.bsky.social", permissions: new Set(["space.atbb.permission.manageRoles"]), }; expect(canManageRoles(auth)).toBe(true); }); it("returns true with wildcard (*) permission", () => { const auth: WebSessionWithPermissions = { authenticated: true, did: "did:plc:x", handle: "x.bsky.social", permissions: new Set(["*"]), }; expect(canManageRoles(auth)).toBe(true); }); });