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 main 2834 lines 101 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 3const mockFetch = vi.fn(); 4 5describe("createAdminRoutes — GET /admin", () => { 6 beforeEach(() => { 7 vi.stubGlobal("fetch", mockFetch); 8 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 vi.resetModules(); 10 }); 11 12 afterEach(() => { 13 vi.unstubAllGlobals(); 14 vi.unstubAllEnvs(); 15 mockFetch.mockReset(); 16 }); 17 18 function mockResponse(body: unknown, ok = true, status = 200) { 19 return { 20 ok, 21 status, 22 statusText: ok ? "OK" : "Error", 23 json: () => Promise.resolve(body), 24 }; 25 } 26 27 /** 28 * Sets up the two-fetch mock sequence for an authenticated session. 29 * Call 1: GET /api/auth/session 30 * Call 2: GET /api/admin/members/me 31 */ 32 function setupAuthenticatedSession(permissions: string[]) { 33 mockFetch.mockResolvedValueOnce( 34 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 35 ); 36 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 37 } 38 39 async function loadAdminRoutes() { 40 const { createAdminRoutes } = await import("../admin.js"); 41 return createAdminRoutes("http://localhost:3000"); 42 } 43 44 // ── Unauthenticated ───────────────────────────────────────────────────── 45 46 it("redirects unauthenticated users to /login", async () => { 47 // No atbb_session cookie → zero fetch calls 48 const routes = await loadAdminRoutes(); 49 const res = await routes.request("/admin"); 50 expect(res.status).toBe(302); 51 expect(res.headers.get("location")).toBe("/login"); 52 }); 53 54 // ── No admin permissions → 403 ────────────────────────────────────────── 55 56 it("returns 403 for authenticated user with no permissions", async () => { 57 setupAuthenticatedSession([]); 58 const routes = await loadAdminRoutes(); 59 const res = await routes.request("/admin", { 60 headers: { cookie: "atbb_session=token" }, 61 }); 62 expect(res.status).toBe(403); 63 const html = await res.text(); 64 expect(html).toContain("Access Denied"); 65 }); 66 67 it("returns 403 for authenticated user with only an unrelated permission", async () => { 68 setupAuthenticatedSession(["space.atbb.permission.someOtherThing"]); 69 const routes = await loadAdminRoutes(); 70 const res = await routes.request("/admin", { 71 headers: { cookie: "atbb_session=token" }, 72 }); 73 expect(res.status).toBe(403); 74 }); 75 76 // ── Wildcard → all cards ───────────────────────────────────────────────── 77 78 it("grants access and shows all cards for wildcard (*) permission", async () => { 79 setupAuthenticatedSession(["*"]); 80 const routes = await loadAdminRoutes(); 81 const res = await routes.request("/admin", { 82 headers: { cookie: "atbb_session=token" }, 83 }); 84 expect(res.status).toBe(200); 85 const html = await res.text(); 86 expect(html).toContain('href="/admin/members"'); 87 expect(html).toContain('href="/admin/structure"'); 88 expect(html).toContain('href="/admin/modlog"'); 89 expect(html).toContain('href="/admin/themes"'); 90 }); 91 92 // ── Single permission → only that card ────────────────────────────────── 93 94 it("shows only Members card for user with only manageMembers", async () => { 95 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 96 const routes = await loadAdminRoutes(); 97 const res = await routes.request("/admin", { 98 headers: { cookie: "atbb_session=token" }, 99 }); 100 expect(res.status).toBe(200); 101 const html = await res.text(); 102 expect(html).toContain('href="/admin/members"'); 103 expect(html).not.toContain('href="/admin/structure"'); 104 expect(html).not.toContain('href="/admin/modlog"'); 105 expect(html).not.toContain('href="/admin/themes"'); 106 }); 107 108 it("shows only Structure card for user with only manageCategories", async () => { 109 setupAuthenticatedSession(["space.atbb.permission.manageCategories"]); 110 const routes = await loadAdminRoutes(); 111 const res = await routes.request("/admin", { 112 headers: { cookie: "atbb_session=token" }, 113 }); 114 expect(res.status).toBe(200); 115 const html = await res.text(); 116 expect(html).not.toContain('href="/admin/members"'); 117 expect(html).toContain('href="/admin/structure"'); 118 expect(html).not.toContain('href="/admin/modlog"'); 119 expect(html).not.toContain('href="/admin/themes"'); 120 }); 121 122 it("shows only Mod Log card for user with only moderatePosts", async () => { 123 setupAuthenticatedSession(["space.atbb.permission.moderatePosts"]); 124 const routes = await loadAdminRoutes(); 125 const res = await routes.request("/admin", { 126 headers: { cookie: "atbb_session=token" }, 127 }); 128 expect(res.status).toBe(200); 129 const html = await res.text(); 130 expect(html).not.toContain('href="/admin/members"'); 131 expect(html).not.toContain('href="/admin/structure"'); 132 expect(html).toContain('href="/admin/modlog"'); 133 expect(html).not.toContain('href="/admin/themes"'); 134 }); 135 136 it("shows only Mod Log card for user with only banUsers", async () => { 137 setupAuthenticatedSession(["space.atbb.permission.banUsers"]); 138 const routes = await loadAdminRoutes(); 139 const res = await routes.request("/admin", { 140 headers: { cookie: "atbb_session=token" }, 141 }); 142 expect(res.status).toBe(200); 143 const html = await res.text(); 144 expect(html).not.toContain('href="/admin/members"'); 145 expect(html).not.toContain('href="/admin/structure"'); 146 expect(html).toContain('href="/admin/modlog"'); 147 expect(html).not.toContain('href="/admin/themes"'); 148 }); 149 150 it("shows only Mod Log card for user with only lockTopics", async () => { 151 setupAuthenticatedSession(["space.atbb.permission.lockTopics"]); 152 const routes = await loadAdminRoutes(); 153 const res = await routes.request("/admin", { 154 headers: { cookie: "atbb_session=token" }, 155 }); 156 expect(res.status).toBe(200); 157 const html = await res.text(); 158 expect(html).not.toContain('href="/admin/members"'); 159 expect(html).not.toContain('href="/admin/structure"'); 160 expect(html).toContain('href="/admin/modlog"'); 161 expect(html).not.toContain('href="/admin/themes"'); 162 }); 163 164 it("shows Themes card for user with manageThemes permission", async () => { 165 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 166 const routes = await loadAdminRoutes(); 167 const res = await routes.request("/admin", { 168 headers: { cookie: "atbb_session=token" }, 169 }); 170 expect(res.status).toBe(200); 171 const html = await res.text(); 172 expect(html).toContain('href="/admin/themes"'); 173 expect(html).toContain("🎨"); 174 expect(html).not.toContain('href="/admin/members"'); 175 expect(html).not.toContain('href="/admin/structure"'); 176 expect(html).not.toContain('href="/admin/modlog"'); 177 }); 178 179 it("does not show Themes card for user with only manageMembers permission", async () => { 180 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 181 const routes = await loadAdminRoutes(); 182 const res = await routes.request("/admin", { 183 headers: { cookie: "atbb_session=token" }, 184 }); 185 expect(res.status).toBe(200); 186 const html = await res.text(); 187 expect(html).not.toContain('href="/admin/themes"'); 188 }); 189 190 it("shows Themes card for wildcard (*) permission user", async () => { 191 setupAuthenticatedSession(["*"]); 192 const routes = await loadAdminRoutes(); 193 const res = await routes.request("/admin", { 194 headers: { cookie: "atbb_session=token" }, 195 }); 196 expect(res.status).toBe(200); 197 const html = await res.text(); 198 expect(html).toContain('href="/admin/themes"'); 199 }); 200 201 it("grants access to /admin landing page for user with only manageThemes", async () => { 202 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 203 const routes = await loadAdminRoutes(); 204 const res = await routes.request("/admin", { 205 headers: { cookie: "atbb_session=token" }, 206 }); 207 // manageThemes should be in ADMIN_PERMISSIONS so the landing page is accessible 208 expect(res.status).toBe(200); 209 }); 210 211 // ── Multi-permission combos ────────────────────────────────────────────── 212 213 it("shows Members and Mod Log cards for manageMembers + moderatePosts", async () => { 214 setupAuthenticatedSession([ 215 "space.atbb.permission.manageMembers", 216 "space.atbb.permission.moderatePosts", 217 ]); 218 const routes = await loadAdminRoutes(); 219 const res = await routes.request("/admin", { 220 headers: { cookie: "atbb_session=token" }, 221 }); 222 expect(res.status).toBe(200); 223 const html = await res.text(); 224 expect(html).toContain('href="/admin/members"'); 225 expect(html).not.toContain('href="/admin/structure"'); 226 expect(html).toContain('href="/admin/modlog"'); 227 expect(html).not.toContain('href="/admin/themes"'); 228 }); 229 230 // ── Page structure ─────────────────────────────────────────────────────── 231 232 it("renders 'Admin Panel' page title", async () => { 233 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 234 const routes = await loadAdminRoutes(); 235 const res = await routes.request("/admin", { 236 headers: { cookie: "atbb_session=token" }, 237 }); 238 const html = await res.text(); 239 expect(html).toContain("Admin Panel"); 240 }); 241 242 it("renders admin-nav-grid container", async () => { 243 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 244 const routes = await loadAdminRoutes(); 245 const res = await routes.request("/admin", { 246 headers: { cookie: "atbb_session=token" }, 247 }); 248 const html = await res.text(); 249 expect(html).toContain("admin-nav-grid"); 250 }); 251}); 252 253describe("createAdminRoutes — GET /admin/members", () => { 254 beforeEach(() => { 255 vi.stubGlobal("fetch", mockFetch); 256 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 257 vi.resetModules(); 258 }); 259 260 afterEach(() => { 261 vi.unstubAllGlobals(); 262 vi.unstubAllEnvs(); 263 mockFetch.mockReset(); 264 }); 265 266 function mockResponse(body: unknown, ok = true, status = 200) { 267 return { 268 ok, 269 status, 270 statusText: ok ? "OK" : "Error", 271 json: () => Promise.resolve(body), 272 }; 273 } 274 275 function setupSession(permissions: string[]) { 276 mockFetch.mockResolvedValueOnce( 277 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 278 ); 279 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 280 } 281 282 const SAMPLE_MEMBERS = [ 283 { 284 did: "did:plc:alice", 285 handle: "alice.bsky.social", 286 role: "Owner", 287 roleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 288 joinedAt: "2026-01-01T00:00:00.000Z", 289 }, 290 { 291 did: "did:plc:bob", 292 handle: "bob.bsky.social", 293 role: "Member", 294 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 295 joinedAt: "2026-01-05T00:00:00.000Z", 296 }, 297 ]; 298 299 const SAMPLE_ROLES = [ 300 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 301 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 302 ]; 303 304 async function loadAdminRoutes() { 305 const { createAdminRoutes } = await import("../admin.js"); 306 return createAdminRoutes("http://localhost:3000"); 307 } 308 309 it("redirects unauthenticated users to /login", async () => { 310 const routes = await loadAdminRoutes(); 311 const res = await routes.request("/admin/members"); 312 expect(res.status).toBe(302); 313 expect(res.headers.get("location")).toBe("/login"); 314 }); 315 316 it("returns 403 for authenticated user without manageMembers", async () => { 317 setupSession(["space.atbb.permission.manageCategories"]); 318 const routes = await loadAdminRoutes(); 319 const res = await routes.request("/admin/members", { 320 headers: { cookie: "atbb_session=token" }, 321 }); 322 expect(res.status).toBe(403); 323 }); 324 325 it("renders member table with handles and role badges", async () => { 326 setupSession(["space.atbb.permission.manageMembers"]); 327 mockFetch.mockResolvedValueOnce( 328 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 329 ); 330 331 const routes = await loadAdminRoutes(); 332 const res = await routes.request("/admin/members", { 333 headers: { cookie: "atbb_session=token" }, 334 }); 335 336 expect(res.status).toBe(200); 337 const html = await res.text(); 338 expect(html).toContain("alice.bsky.social"); 339 expect(html).toContain("bob.bsky.social"); 340 expect(html).toContain("role-badge"); 341 expect(html).toContain("Owner"); 342 }); 343 344 it("renders joined date for members", async () => { 345 setupSession(["space.atbb.permission.manageMembers"]); 346 mockFetch.mockResolvedValueOnce( 347 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 348 ); 349 350 const routes = await loadAdminRoutes(); 351 const res = await routes.request("/admin/members", { 352 headers: { cookie: "atbb_session=token" }, 353 }); 354 355 const html = await res.text(); 356 expect(html).toContain("Jan"); 357 expect(html).toContain("2026"); 358 }); 359 360 it("hides role assignment form when user lacks manageRoles", async () => { 361 setupSession(["space.atbb.permission.manageMembers"]); 362 mockFetch.mockResolvedValueOnce( 363 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 364 ); 365 366 const routes = await loadAdminRoutes(); 367 const res = await routes.request("/admin/members", { 368 headers: { cookie: "atbb_session=token" }, 369 }); 370 371 const html = await res.text(); 372 expect(html).not.toContain("hx-post"); 373 expect(html).not.toContain("Assign"); 374 }); 375 376 it("shows role assignment form when user has manageRoles", async () => { 377 setupSession([ 378 "space.atbb.permission.manageMembers", 379 "space.atbb.permission.manageRoles", 380 ]); 381 mockFetch.mockResolvedValueOnce( 382 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 383 ); 384 mockFetch.mockResolvedValueOnce(mockResponse({ roles: SAMPLE_ROLES })); 385 386 const routes = await loadAdminRoutes(); 387 const res = await routes.request("/admin/members", { 388 headers: { cookie: "atbb_session=token" }, 389 }); 390 391 const html = await res.text(); 392 expect(html).toContain("hx-post"); 393 expect(html).toContain("/admin/members/did:plc:bob/role"); 394 expect(html).toContain("Assign"); 395 }); 396 397 it("shows empty state when no members", async () => { 398 setupSession(["space.atbb.permission.manageMembers"]); 399 mockFetch.mockResolvedValueOnce( 400 mockResponse({ members: [], isTruncated: false }) 401 ); 402 403 const routes = await loadAdminRoutes(); 404 const res = await routes.request("/admin/members", { 405 headers: { cookie: "atbb_session=token" }, 406 }); 407 408 const html = await res.text(); 409 expect(html).toContain("No members"); 410 }); 411 412 it("shows truncated indicator when isTruncated is true", async () => { 413 setupSession(["space.atbb.permission.manageMembers"]); 414 mockFetch.mockResolvedValueOnce( 415 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: true }) 416 ); 417 418 const routes = await loadAdminRoutes(); 419 const res = await routes.request("/admin/members", { 420 headers: { cookie: "atbb_session=token" }, 421 }); 422 423 const html = await res.text(); 424 expect(html).toContain("+"); 425 }); 426 427 it("returns 503 on AppView network error fetching members", async () => { 428 setupSession(["space.atbb.permission.manageMembers"]); 429 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 430 431 const routes = await loadAdminRoutes(); 432 const res = await routes.request("/admin/members", { 433 headers: { cookie: "atbb_session=token" }, 434 }); 435 436 expect(res.status).toBe(503); 437 const html = await res.text(); 438 expect(html).toContain("error-display"); 439 }); 440 441 it("returns 500 on AppView server error fetching members", async () => { 442 setupSession(["space.atbb.permission.manageMembers"]); 443 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 444 445 const routes = await loadAdminRoutes(); 446 const res = await routes.request("/admin/members", { 447 headers: { cookie: "atbb_session=token" }, 448 }); 449 450 expect(res.status).toBe(500); 451 const html = await res.text(); 452 expect(html).toContain("error-display"); 453 }); 454 455 it("redirects to /login when AppView members returns 401 (session expired)", async () => { 456 setupSession(["space.atbb.permission.manageMembers"]); 457 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 458 459 const routes = await loadAdminRoutes(); 460 const res = await routes.request("/admin/members", { 461 headers: { cookie: "atbb_session=token" }, 462 }); 463 464 expect(res.status).toBe(302); 465 expect(res.headers.get("location")).toBe("/login"); 466 }); 467 468 it("renders page with empty role dropdown when roles fetch fails", async () => { 469 setupSession([ 470 "space.atbb.permission.manageMembers", 471 "space.atbb.permission.manageRoles", 472 ]); 473 // members fetch succeeds 474 mockFetch.mockResolvedValueOnce( 475 mockResponse({ members: SAMPLE_MEMBERS, isTruncated: false }) 476 ); 477 // roles fetch fails 478 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 479 480 const routes = await loadAdminRoutes(); 481 const res = await routes.request("/admin/members", { 482 headers: { cookie: "atbb_session=token" }, 483 }); 484 485 expect(res.status).toBe(200); 486 const html = await res.text(); 487 // Page still renders with member data 488 expect(html).toContain("alice.bsky.social"); 489 // Assign Role column still present (permission says yes, just no options) 490 expect(html).toContain("hx-post"); 491 }); 492}); 493 494describe("createAdminRoutes — POST /admin/members/:did/role", () => { 495 beforeEach(() => { 496 vi.stubGlobal("fetch", mockFetch); 497 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 498 vi.resetModules(); 499 }); 500 501 afterEach(() => { 502 vi.unstubAllGlobals(); 503 vi.unstubAllEnvs(); 504 mockFetch.mockReset(); 505 }); 506 507 function mockResponse(body: unknown, ok = true, status = 200) { 508 return { 509 ok, 510 status, 511 statusText: ok ? "OK" : "Error", 512 json: () => Promise.resolve(body), 513 }; 514 } 515 516 const SAMPLE_ROLES = [ 517 { id: "1", name: "Owner", uri: "at://did:plc:forum/space.atbb.forum.role/owner", priority: 0, permissions: ["*"] }, 518 { id: "2", name: "Member", uri: "at://did:plc:forum/space.atbb.forum.role/member", priority: 30, permissions: [] }, 519 ]; 520 521 function makeFormBody(overrides: Partial<Record<string, string>> = {}): string { 522 return new URLSearchParams({ 523 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 524 handle: "bob.bsky.social", 525 joinedAt: "2026-01-05T00:00:00.000Z", 526 currentRole: "Owner", 527 currentRoleUri: "at://did:plc:forum/space.atbb.forum.role/owner", 528 canManageRoles: "1", 529 rolesJson: JSON.stringify(SAMPLE_ROLES), 530 ...overrides, 531 }).toString(); 532 } 533 534 async function loadAdminRoutes() { 535 const { createAdminRoutes } = await import("../admin.js"); 536 return createAdminRoutes("http://localhost:3000"); 537 } 538 539 function setupPostSession(permissions: string[] = ["space.atbb.permission.manageRoles"]) { 540 mockFetch.mockResolvedValueOnce( 541 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 542 ); 543 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 544 } 545 546 it("returns updated <tr> with new role name on success", async () => { 547 setupPostSession(); 548 mockFetch.mockResolvedValueOnce( 549 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 550 ); 551 552 const routes = await loadAdminRoutes(); 553 const res = await routes.request("/admin/members/did:plc:bob/role", { 554 method: "POST", 555 headers: { 556 "Content-Type": "application/x-www-form-urlencoded", 557 cookie: "atbb_session=token", 558 }, 559 body: makeFormBody(), 560 }); 561 562 expect(res.status).toBe(200); 563 const html = await res.text(); 564 expect(html).toContain("<tr"); 565 expect(html).toContain("Member"); 566 expect(html).toContain("bob.bsky.social"); 567 }); 568 569 it("returns row with friendly error on AppView 403", async () => { 570 setupPostSession(); 571 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 403)); 572 573 const routes = await loadAdminRoutes(); 574 const res = await routes.request("/admin/members/did:plc:bob/role", { 575 method: "POST", 576 headers: { 577 "Content-Type": "application/x-www-form-urlencoded", 578 cookie: "atbb_session=token", 579 }, 580 body: makeFormBody(), 581 }); 582 583 expect(res.status).toBe(200); 584 const html = await res.text(); 585 expect(html).toContain("member-row__error"); 586 expect(html).toContain("equal or higher authority"); 587 expect(html).toContain("Owner"); // preserves current role 588 }); 589 590 it("returns row with friendly error on AppView 404", async () => { 591 setupPostSession(); 592 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 593 594 const routes = await loadAdminRoutes(); 595 const res = await routes.request("/admin/members/did:plc:bob/role", { 596 method: "POST", 597 headers: { 598 "Content-Type": "application/x-www-form-urlencoded", 599 cookie: "atbb_session=token", 600 }, 601 body: makeFormBody(), 602 }); 603 604 expect(res.status).toBe(200); 605 const html = await res.text(); 606 expect(html).toContain("member-row__error"); 607 expect(html).toContain("not found"); 608 }); 609 610 it("returns row with friendly error on AppView 500", async () => { 611 setupPostSession(); 612 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 613 614 const routes = await loadAdminRoutes(); 615 const res = await routes.request("/admin/members/did:plc:bob/role", { 616 method: "POST", 617 headers: { 618 "Content-Type": "application/x-www-form-urlencoded", 619 cookie: "atbb_session=token", 620 }, 621 body: makeFormBody(), 622 }); 623 624 expect(res.status).toBe(200); 625 const html = await res.text(); 626 expect(html).toContain("member-row__error"); 627 expect(html).toContain("Something went wrong"); 628 }); 629 630 it("returns row with unavailable message on network error", async () => { 631 setupPostSession(); 632 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 633 634 const routes = await loadAdminRoutes(); 635 const res = await routes.request("/admin/members/did:plc:bob/role", { 636 method: "POST", 637 headers: { 638 "Content-Type": "application/x-www-form-urlencoded", 639 cookie: "atbb_session=token", 640 }, 641 body: makeFormBody(), 642 }); 643 644 expect(res.status).toBe(200); 645 const html = await res.text(); 646 expect(html).toContain("member-row__error"); 647 expect(html).toContain("temporarily unavailable"); 648 }); 649 650 it("returns row with error and makes no AppView call when roleUri is missing", async () => { 651 setupPostSession(); 652 const routes = await loadAdminRoutes(); 653 const res = await routes.request("/admin/members/did:plc:bob/role", { 654 method: "POST", 655 headers: { 656 "Content-Type": "application/x-www-form-urlencoded", 657 cookie: "atbb_session=token", 658 }, 659 body: makeFormBody({ roleUri: "" }), 660 }); 661 662 expect(res.status).toBe(200); 663 const html = await res.text(); 664 expect(html).toContain("member-row__error"); 665 expect(mockFetch).not.toHaveBeenCalledWith( 666 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 667 expect.anything() 668 ); 669 }); 670 671 it("re-renders form with new role pre-selected in dropdown on success", async () => { 672 setupPostSession(); 673 mockFetch.mockResolvedValueOnce( 674 mockResponse({ success: true, roleAssigned: "Member", targetDid: "did:plc:bob" }) 675 ); 676 677 const routes = await loadAdminRoutes(); 678 const res = await routes.request("/admin/members/did:plc:bob/role", { 679 method: "POST", 680 headers: { 681 "Content-Type": "application/x-www-form-urlencoded", 682 cookie: "atbb_session=token", 683 }, 684 body: makeFormBody({ 685 roleUri: "at://did:plc:forum/space.atbb.forum.role/member", 686 }), 687 }); 688 689 const html = await res.text(); 690 // The newly assigned role URI should appear as the selected option value in the form 691 expect(html).toContain("at://did:plc:forum/space.atbb.forum.role/member"); 692 }); 693 694 it("returns 401 error row for unauthenticated POST", async () => { 695 // No session mock — no cookie 696 const routes = await loadAdminRoutes(); 697 const res = await routes.request("/admin/members/did:plc:bob/role", { 698 method: "POST", 699 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 700 body: makeFormBody(), 701 }); 702 703 expect(res.status).toBe(401); 704 const html = await res.text(); 705 expect(html).toContain("member-row__error"); 706 expect(mockFetch).not.toHaveBeenCalledWith( 707 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 708 expect.anything() 709 ); 710 }); 711 712 it("returns 403 error row when user lacks manageRoles", async () => { 713 setupPostSession(["space.atbb.permission.manageMembers"]); // has manageMembers but NOT manageRoles 714 const routes = await loadAdminRoutes(); 715 const res = await routes.request("/admin/members/did:plc:bob/role", { 716 method: "POST", 717 headers: { 718 "Content-Type": "application/x-www-form-urlencoded", 719 cookie: "atbb_session=token", 720 }, 721 body: makeFormBody(), 722 }); 723 724 expect(res.status).toBe(403); 725 const html = await res.text(); 726 expect(html).toContain("member-row__error"); 727 // No AppView role assignment call should have been made 728 expect(mockFetch).not.toHaveBeenCalledWith( 729 expect.stringContaining("/api/admin/members/did:plc:bob/role"), 730 expect.anything() 731 ); 732 }); 733 734 it("returns row with session-expired error when AppView returns 401", async () => { 735 setupPostSession(); 736 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 737 738 const routes = await loadAdminRoutes(); 739 const res = await routes.request("/admin/members/did:plc:bob/role", { 740 method: "POST", 741 headers: { 742 "Content-Type": "application/x-www-form-urlencoded", 743 cookie: "atbb_session=token", 744 }, 745 body: makeFormBody(), 746 }); 747 748 expect(res.status).toBe(200); 749 const html = await res.text(); 750 expect(html).toContain("member-row__error"); 751 expect(html).toContain("session has expired"); 752 }); 753 754 it("returns error row with reload message when rolesJson is malformed", async () => { 755 setupPostSession(); 756 757 const routes = await loadAdminRoutes(); 758 const res = await routes.request("/admin/members/did:plc:bob/role", { 759 method: "POST", 760 headers: { 761 "Content-Type": "application/x-www-form-urlencoded", 762 cookie: "atbb_session=token", 763 }, 764 body: makeFormBody({ rolesJson: "not-valid-json{{" }), 765 }); 766 767 expect(res.status).toBe(200); 768 const html = await res.text(); 769 expect(html).toContain("member-row__error"); 770 expect(html).toContain("reload"); 771 // No AppView call should have been made 772 // (setupPostSession consumed 2 calls, then we check no more were made) 773 expect(mockFetch).toHaveBeenCalledTimes(2); 774 }); 775 776 it("returns error row and makes no AppView call when targetDid lacks did: prefix", async () => { 777 setupPostSession(); 778 779 const routes = await loadAdminRoutes(); 780 const res = await routes.request("/admin/members/notadid/role", { 781 method: "POST", 782 headers: { 783 "Content-Type": "application/x-www-form-urlencoded", 784 cookie: "atbb_session=token", 785 }, 786 body: makeFormBody({ handle: "bob.bsky.social" }), 787 }); 788 789 expect(res.status).toBe(200); 790 const html = await res.text(); 791 expect(html).toContain("member-row__error"); 792 expect(html).toContain("Invalid member identifier"); 793 // Session fetch calls consumed (2), but no AppView role call made 794 expect(mockFetch).not.toHaveBeenCalledWith( 795 expect.stringContaining("/api/admin/members/notadid/role"), 796 expect.anything() 797 ); 798 }); 799}); 800 801describe("createAdminRoutes — GET /admin/structure", () => { 802 beforeEach(() => { 803 vi.stubGlobal("fetch", mockFetch); 804 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 805 vi.resetModules(); 806 }); 807 808 afterEach(() => { 809 vi.unstubAllGlobals(); 810 vi.unstubAllEnvs(); 811 mockFetch.mockReset(); 812 }); 813 814 function mockResponse(body: unknown, ok = true, status = 200) { 815 return { 816 ok, 817 status, 818 statusText: ok ? "OK" : "Error", 819 json: () => Promise.resolve(body), 820 }; 821 } 822 823 function setupSession(permissions: string[]) { 824 mockFetch.mockResolvedValueOnce( 825 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 826 ); 827 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 828 } 829 830 /** 831 * Sets up mock responses for the structure page data fetches. 832 * After the 2 session calls: 833 * Call 3: GET /api/categories 834 * Call 4+: GET /api/categories/:id/boards (one per category, parallel) 835 */ 836 function setupStructureFetch( 837 cats: Array<{ id: string; name: string; uri: string; sortOrder?: number }>, 838 boardsByCategory: Record<string, Array<{ id: string; name: string }>> = {} 839 ) { 840 mockFetch.mockResolvedValueOnce( 841 mockResponse({ 842 categories: cats.map((c) => ({ 843 id: c.id, 844 did: "did:plc:forum", 845 uri: c.uri, 846 name: c.name, 847 description: null, 848 slug: null, 849 sortOrder: c.sortOrder ?? 1, 850 forumId: "1", 851 createdAt: "2025-01-01T00:00:00.000Z", 852 indexedAt: "2025-01-01T00:00:00.000Z", 853 })), 854 }) 855 ); 856 for (const cat of cats) { 857 const boards = boardsByCategory[cat.id] ?? []; 858 mockFetch.mockResolvedValueOnce( 859 mockResponse({ 860 boards: boards.map((b) => ({ 861 id: b.id, 862 did: "did:plc:forum", 863 uri: `at://did:plc:forum/space.atbb.forum.board/${b.id}`, 864 name: b.name, 865 description: null, 866 slug: null, 867 sortOrder: 1, 868 categoryId: cat.id, 869 categoryUri: cat.uri, 870 createdAt: "2025-01-01T00:00:00.000Z", 871 indexedAt: "2025-01-01T00:00:00.000Z", 872 })), 873 }) 874 ); 875 } 876 } 877 878 async function loadAdminRoutes() { 879 const { createAdminRoutes } = await import("../admin.js"); 880 return createAdminRoutes("http://localhost:3000"); 881 } 882 883 it("redirects unauthenticated users to /login", async () => { 884 mockFetch.mockResolvedValueOnce( 885 mockResponse({ authenticated: false }) 886 ); 887 const routes = await loadAdminRoutes(); 888 const res = await routes.request("/admin/structure"); 889 expect(res.status).toBe(302); 890 expect(res.headers.get("location")).toBe("/login"); 891 }); 892 893 it("returns 403 for authenticated user without manageCategories", async () => { 894 setupSession(["space.atbb.permission.manageMembers"]); 895 const routes = await loadAdminRoutes(); 896 const res = await routes.request("/admin/structure", { 897 headers: { cookie: "atbb_session=token" }, 898 }); 899 expect(res.status).toBe(403); 900 }); 901 902 it("renders structure page with category and board names", async () => { 903 setupSession(["space.atbb.permission.manageCategories"]); 904 setupStructureFetch( 905 [{ id: "1", name: "General Discussion", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 906 { "1": [{ id: "10", name: "General Chat" }] } 907 ); 908 909 const routes = await loadAdminRoutes(); 910 const res = await routes.request("/admin/structure", { 911 headers: { cookie: "atbb_session=token" }, 912 }); 913 914 expect(res.status).toBe(200); 915 const html = await res.text(); 916 expect(html).toContain("General Discussion"); 917 expect(html).toContain("General Chat"); 918 }); 919 920 it("renders empty state when no categories exist", async () => { 921 setupSession(["space.atbb.permission.manageCategories"]); 922 setupStructureFetch([]); 923 924 const routes = await loadAdminRoutes(); 925 const res = await routes.request("/admin/structure", { 926 headers: { cookie: "atbb_session=token" }, 927 }); 928 929 expect(res.status).toBe(200); 930 const html = await res.text(); 931 expect(html).toContain("No categories"); 932 }); 933 934 it("renders the add-category form", async () => { 935 setupSession(["space.atbb.permission.manageCategories"]); 936 setupStructureFetch([]); 937 938 const routes = await loadAdminRoutes(); 939 const res = await routes.request("/admin/structure", { 940 headers: { cookie: "atbb_session=token" }, 941 }); 942 943 const html = await res.text(); 944 expect(html).toContain('action="/admin/structure/categories"'); 945 }); 946 947 it("renders edit and delete actions for a category", async () => { 948 setupSession(["space.atbb.permission.manageCategories"]); 949 setupStructureFetch( 950 [{ id: "5", name: "Projects", uri: "at://did:plc:forum/space.atbb.forum.category/xyz" }], 951 ); 952 953 const routes = await loadAdminRoutes(); 954 const res = await routes.request("/admin/structure", { 955 headers: { cookie: "atbb_session=token" }, 956 }); 957 958 const html = await res.text(); 959 expect(html).toContain('action="/admin/structure/categories/5/edit"'); 960 expect(html).toContain('action="/admin/structure/categories/5/delete"'); 961 }); 962 963 it("renders edit and delete actions for a board", async () => { 964 setupSession(["space.atbb.permission.manageCategories"]); 965 setupStructureFetch( 966 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 967 { "1": [{ id: "20", name: "Showcase" }] } 968 ); 969 970 const routes = await loadAdminRoutes(); 971 const res = await routes.request("/admin/structure", { 972 headers: { cookie: "atbb_session=token" }, 973 }); 974 975 const html = await res.text(); 976 expect(html).toContain("Showcase"); 977 expect(html).toContain('action="/admin/structure/boards/20/edit"'); 978 expect(html).toContain('action="/admin/structure/boards/20/delete"'); 979 }); 980 981 it("renders add-board form with categoryUri hidden input", async () => { 982 setupSession(["space.atbb.permission.manageCategories"]); 983 setupStructureFetch( 984 [{ id: "1", name: "General", uri: "at://did:plc:forum/space.atbb.forum.category/abc" }], 985 ); 986 987 const routes = await loadAdminRoutes(); 988 const res = await routes.request("/admin/structure", { 989 headers: { cookie: "atbb_session=token" }, 990 }); 991 992 const html = await res.text(); 993 expect(html).toContain('name="categoryUri"'); 994 expect(html).toContain('value="at://did:plc:forum/space.atbb.forum.category/abc"'); 995 expect(html).toContain('action="/admin/structure/boards"'); 996 }); 997 998 it("renders error banner when ?error= query param is present", async () => { 999 setupSession(["space.atbb.permission.manageCategories"]); 1000 setupStructureFetch([]); 1001 1002 const routes = await loadAdminRoutes(); 1003 const res = await routes.request( 1004 `/admin/structure?error=${encodeURIComponent("Cannot delete category with boards. Remove all boards first.")}`, 1005 { headers: { cookie: "atbb_session=token" } } 1006 ); 1007 1008 const html = await res.text(); 1009 expect(html).toContain("Cannot delete category with boards"); 1010 }); 1011 1012 it("returns 503 on AppView network error fetching categories", async () => { 1013 setupSession(["space.atbb.permission.manageCategories"]); 1014 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1015 1016 const routes = await loadAdminRoutes(); 1017 const res = await routes.request("/admin/structure", { 1018 headers: { cookie: "atbb_session=token" }, 1019 }); 1020 1021 expect(res.status).toBe(503); 1022 const html = await res.text(); 1023 expect(html).toContain("error-display"); 1024 }); 1025 1026 it("returns 500 on AppView server error fetching categories", async () => { 1027 setupSession(["space.atbb.permission.manageCategories"]); 1028 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 1029 1030 const routes = await loadAdminRoutes(); 1031 const res = await routes.request("/admin/structure", { 1032 headers: { cookie: "atbb_session=token" }, 1033 }); 1034 1035 expect(res.status).toBe(500); 1036 const html = await res.text(); 1037 expect(html).toContain("error-display"); 1038 }); 1039 1040 it("redirects to /login when AppView categories returns 401", async () => { 1041 setupSession(["space.atbb.permission.manageCategories"]); 1042 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 1043 1044 const routes = await loadAdminRoutes(); 1045 const res = await routes.request("/admin/structure", { 1046 headers: { cookie: "atbb_session=token" }, 1047 }); 1048 1049 expect(res.status).toBe(302); 1050 expect(res.headers.get("location")).toBe("/login"); 1051 }); 1052}); 1053 1054describe("createAdminRoutes — POST /admin/structure/categories", () => { 1055 beforeEach(() => { 1056 vi.stubGlobal("fetch", mockFetch); 1057 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1058 vi.resetModules(); 1059 }); 1060 1061 afterEach(() => { 1062 vi.unstubAllGlobals(); 1063 vi.unstubAllEnvs(); 1064 mockFetch.mockReset(); 1065 }); 1066 1067 function mockResponse(body: unknown, ok = true, status = 200) { 1068 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1069 } 1070 1071 function setupSession(permissions: string[]) { 1072 mockFetch.mockResolvedValueOnce( 1073 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1074 ); 1075 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1076 } 1077 1078 async function loadAdminRoutes() { 1079 const { createAdminRoutes } = await import("../admin.js"); 1080 return createAdminRoutes("http://localhost:3000"); 1081 } 1082 1083 function postForm(body: Record<string, string>) { 1084 const params = new URLSearchParams(body); 1085 return { 1086 method: "POST", 1087 headers: { 1088 cookie: "atbb_session=token", 1089 "content-type": "application/x-www-form-urlencoded", 1090 }, 1091 body: params.toString(), 1092 }; 1093 } 1094 1095 it("redirects to /login when unauthenticated", async () => { 1096 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1097 const routes = await loadAdminRoutes(); 1098 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1099 expect(res.status).toBe(302); 1100 expect(res.headers.get("location")).toBe("/login"); 1101 }); 1102 1103 it("returns 403 without manageCategories permission", async () => { 1104 setupSession(["space.atbb.permission.manageMembers"]); 1105 const routes = await loadAdminRoutes(); 1106 const res = await routes.request("/admin/structure/categories", postForm({ name: "General" })); 1107 expect(res.status).toBe(403); 1108 }); 1109 1110 it("redirects to /admin/structure on success", async () => { 1111 setupSession(["space.atbb.permission.manageCategories"]); 1112 mockFetch.mockResolvedValueOnce( 1113 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.category/abc", cid: "bafyrei..." }, true, 201) 1114 ); 1115 1116 const routes = await loadAdminRoutes(); 1117 const res = await routes.request( 1118 "/admin/structure/categories", 1119 postForm({ name: "General", description: "Talk about anything", sortOrder: "1" }) 1120 ); 1121 1122 expect(res.status).toBe(302); 1123 expect(res.headers.get("location")).toBe("/admin/structure"); 1124 }); 1125 1126 it("redirects with ?error= when name is missing", async () => { 1127 setupSession(["space.atbb.permission.manageCategories"]); 1128 1129 const routes = await loadAdminRoutes(); 1130 const res = await routes.request( 1131 "/admin/structure/categories", 1132 postForm({ name: "" }) 1133 ); 1134 1135 expect(res.status).toBe(302); 1136 const location = res.headers.get("location") ?? ""; 1137 expect(location).toContain("/admin/structure"); 1138 expect(location).toContain("error="); 1139 }); 1140 1141 it("redirects with ?error= on AppView error", async () => { 1142 setupSession(["space.atbb.permission.manageCategories"]); 1143 mockFetch.mockResolvedValueOnce( 1144 mockResponse({ error: "Unexpected error" }, false, 500) 1145 ); 1146 1147 const routes = await loadAdminRoutes(); 1148 const res = await routes.request( 1149 "/admin/structure/categories", 1150 postForm({ name: "General" }) 1151 ); 1152 1153 expect(res.status).toBe(302); 1154 const location = res.headers.get("location") ?? ""; 1155 expect(location).toContain("/admin/structure"); 1156 expect(location).toContain("error="); 1157 }); 1158 1159 it("redirects with ?error= on network error", async () => { 1160 setupSession(["space.atbb.permission.manageCategories"]); 1161 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1162 1163 const routes = await loadAdminRoutes(); 1164 const res = await routes.request( 1165 "/admin/structure/categories", 1166 postForm({ name: "General" }) 1167 ); 1168 1169 expect(res.status).toBe(302); 1170 const location = res.headers.get("location") ?? ""; 1171 expect(location).toContain("/admin/structure"); 1172 expect(location).toContain("error="); 1173 }); 1174 1175 it("redirects with ?error= for negative sort order", async () => { 1176 setupSession(["space.atbb.permission.manageCategories"]); 1177 1178 const routes = await loadAdminRoutes(); 1179 const res = await routes.request( 1180 "/admin/structure/categories", 1181 postForm({ name: "General", sortOrder: "-1" }) 1182 ); 1183 1184 expect(res.status).toBe(302); 1185 const location = res.headers.get("location") ?? ""; 1186 expect(location).toContain("error="); 1187 }); 1188}); 1189 1190describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { 1191 beforeEach(() => { 1192 vi.stubGlobal("fetch", mockFetch); 1193 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1194 vi.resetModules(); 1195 }); 1196 1197 afterEach(() => { 1198 vi.unstubAllGlobals(); 1199 vi.unstubAllEnvs(); 1200 mockFetch.mockReset(); 1201 }); 1202 1203 function mockResponse(body: unknown, ok = true, status = 200) { 1204 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1205 } 1206 1207 function setupSession(permissions: string[]) { 1208 mockFetch.mockResolvedValueOnce( 1209 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1210 ); 1211 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1212 } 1213 1214 async function loadAdminRoutes() { 1215 const { createAdminRoutes } = await import("../admin.js"); 1216 return createAdminRoutes("http://localhost:3000"); 1217 } 1218 1219 function postForm(body: Record<string, string>) { 1220 const params = new URLSearchParams(body); 1221 return { 1222 method: "POST", 1223 headers: { 1224 cookie: "atbb_session=token", 1225 "content-type": "application/x-www-form-urlencoded", 1226 }, 1227 body: params.toString(), 1228 }; 1229 } 1230 1231 it("redirects to /login when unauthenticated", async () => { 1232 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1233 const routes = await loadAdminRoutes(); 1234 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1235 expect(res.status).toBe(302); 1236 expect(res.headers.get("location")).toBe("/login"); 1237 }); 1238 1239 it("returns 403 without manageCategories", async () => { 1240 setupSession(["space.atbb.permission.manageMembers"]); 1241 const routes = await loadAdminRoutes(); 1242 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1243 expect(res.status).toBe(403); 1244 }); 1245 1246 it("redirects to /admin/structure on success", async () => { 1247 setupSession(["space.atbb.permission.manageCategories"]); 1248 mockFetch.mockResolvedValueOnce( 1249 mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200) 1250 ); 1251 1252 const routes = await loadAdminRoutes(); 1253 const res = await routes.request( 1254 "/admin/structure/categories/5/edit", 1255 postForm({ name: "Updated Name", description: "", sortOrder: "2" }) 1256 ); 1257 1258 expect(res.status).toBe(302); 1259 expect(res.headers.get("location")).toBe("/admin/structure"); 1260 }); 1261 1262 it("redirects with ?error= when name is missing", async () => { 1263 setupSession(["space.atbb.permission.manageCategories"]); 1264 const routes = await loadAdminRoutes(); 1265 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "" })); 1266 expect(res.status).toBe(302); 1267 const location = res.headers.get("location") ?? ""; 1268 expect(location).toContain("error="); 1269 }); 1270 1271 it("redirects with ?error= on AppView error", async () => { 1272 setupSession(["space.atbb.permission.manageCategories"]); 1273 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Not found" }, false, 404)); 1274 const routes = await loadAdminRoutes(); 1275 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1276 expect(res.status).toBe(302); 1277 const location = res.headers.get("location") ?? ""; 1278 expect(location).toContain("error="); 1279 }); 1280 1281 it("redirects with ?error= on network error", async () => { 1282 setupSession(["space.atbb.permission.manageCategories"]); 1283 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1284 const routes = await loadAdminRoutes(); 1285 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1286 expect(res.status).toBe(302); 1287 const location = res.headers.get("location") ?? ""; 1288 expect(location).toContain("error="); 1289 }); 1290 1291 it("redirects with ?error= for negative sort order", async () => { 1292 setupSession(["space.atbb.permission.manageCategories"]); 1293 const routes = await loadAdminRoutes(); 1294 const res = await routes.request( 1295 "/admin/structure/categories/5/edit", 1296 postForm({ name: "Updated", sortOrder: "-5" }) 1297 ); 1298 expect(res.status).toBe(302); 1299 const location = res.headers.get("location") ?? ""; 1300 expect(location).toContain("error="); 1301 }); 1302}); 1303 1304describe("createAdminRoutes — POST /admin/structure/categories/:id/delete", () => { 1305 beforeEach(() => { 1306 vi.stubGlobal("fetch", mockFetch); 1307 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1308 vi.resetModules(); 1309 }); 1310 1311 afterEach(() => { 1312 vi.unstubAllGlobals(); 1313 vi.unstubAllEnvs(); 1314 mockFetch.mockReset(); 1315 }); 1316 1317 function mockResponse(body: unknown, ok = true, status = 200) { 1318 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1319 } 1320 1321 function setupSession(permissions: string[]) { 1322 mockFetch.mockResolvedValueOnce( 1323 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1324 ); 1325 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1326 } 1327 1328 async function loadAdminRoutes() { 1329 const { createAdminRoutes } = await import("../admin.js"); 1330 return createAdminRoutes("http://localhost:3000"); 1331 } 1332 1333 function postForm(body: Record<string, string> = {}) { 1334 const params = new URLSearchParams(body); 1335 return { 1336 method: "POST", 1337 headers: { 1338 cookie: "atbb_session=token", 1339 "content-type": "application/x-www-form-urlencoded", 1340 }, 1341 body: params.toString(), 1342 }; 1343 } 1344 1345 it("redirects to /login when unauthenticated", async () => { 1346 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1347 const routes = await loadAdminRoutes(); 1348 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1349 expect(res.status).toBe(302); 1350 expect(res.headers.get("location")).toBe("/login"); 1351 }); 1352 1353 it("returns 403 without manageCategories", async () => { 1354 setupSession(["space.atbb.permission.manageMembers"]); 1355 const routes = await loadAdminRoutes(); 1356 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1357 expect(res.status).toBe(403); 1358 }); 1359 1360 it("redirects to /admin/structure on success", async () => { 1361 setupSession(["space.atbb.permission.manageCategories"]); 1362 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1363 1364 const routes = await loadAdminRoutes(); 1365 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1366 1367 expect(res.status).toBe(302); 1368 expect(res.headers.get("location")).toBe("/admin/structure"); 1369 }); 1370 1371 it("redirects with ?error= on AppView error (e.g. 409 has boards)", async () => { 1372 setupSession(["space.atbb.permission.manageCategories"]); 1373 mockFetch.mockResolvedValueOnce( 1374 mockResponse({ error: "Cannot delete category with boards. Remove all boards first." }, false, 409) 1375 ); 1376 1377 const routes = await loadAdminRoutes(); 1378 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1379 1380 expect(res.status).toBe(302); 1381 const location = res.headers.get("location") ?? ""; 1382 expect(location).toContain("/admin/structure"); 1383 expect(location).toContain("error="); 1384 expect(decodeURIComponent(location)).toContain("Cannot delete category with boards"); 1385 }); 1386 1387 it("redirects with ?error= on network error", async () => { 1388 setupSession(["space.atbb.permission.manageCategories"]); 1389 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1390 1391 const routes = await loadAdminRoutes(); 1392 const res = await routes.request("/admin/structure/categories/5/delete", postForm()); 1393 1394 expect(res.status).toBe(302); 1395 const location = res.headers.get("location") ?? ""; 1396 expect(location).toContain("error="); 1397 }); 1398}); 1399 1400describe("createAdminRoutes — POST /admin/structure/boards", () => { 1401 beforeEach(() => { 1402 vi.stubGlobal("fetch", mockFetch); 1403 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1404 vi.resetModules(); 1405 }); 1406 1407 afterEach(() => { 1408 vi.unstubAllGlobals(); 1409 vi.unstubAllEnvs(); 1410 mockFetch.mockReset(); 1411 }); 1412 1413 function mockResponse(body: unknown, ok = true, status = 200) { 1414 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1415 } 1416 1417 function setupSession(permissions: string[]) { 1418 mockFetch.mockResolvedValueOnce( 1419 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1420 ); 1421 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1422 } 1423 1424 async function loadAdminRoutes() { 1425 const { createAdminRoutes } = await import("../admin.js"); 1426 return createAdminRoutes("http://localhost:3000"); 1427 } 1428 1429 function postForm(body: Record<string, string>) { 1430 const params = new URLSearchParams(body); 1431 return { 1432 method: "POST", 1433 headers: { 1434 cookie: "atbb_session=token", 1435 "content-type": "application/x-www-form-urlencoded", 1436 }, 1437 body: params.toString(), 1438 }; 1439 } 1440 1441 it("redirects to /login when unauthenticated", async () => { 1442 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1443 const routes = await loadAdminRoutes(); 1444 const res = await routes.request( 1445 "/admin/structure/boards", 1446 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1447 ); 1448 expect(res.status).toBe(302); 1449 expect(res.headers.get("location")).toBe("/login"); 1450 }); 1451 1452 it("returns 403 without manageCategories permission", async () => { 1453 setupSession(["space.atbb.permission.manageMembers"]); 1454 const routes = await loadAdminRoutes(); 1455 const res = await routes.request( 1456 "/admin/structure/boards", 1457 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1458 ); 1459 expect(res.status).toBe(403); 1460 }); 1461 1462 it("redirects to /admin/structure on success", async () => { 1463 setupSession(["space.atbb.permission.manageCategories"]); 1464 mockFetch.mockResolvedValueOnce( 1465 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.board/xyz", cid: "bafyrei..." }, true, 201) 1466 ); 1467 1468 const routes = await loadAdminRoutes(); 1469 const res = await routes.request( 1470 "/admin/structure/boards", 1471 postForm({ 1472 name: "General Chat", 1473 description: "Chat about anything", 1474 sortOrder: "1", 1475 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1476 }) 1477 ); 1478 1479 expect(res.status).toBe(302); 1480 expect(res.headers.get("location")).toBe("/admin/structure"); 1481 }); 1482 1483 it("redirects with ?error= when name is missing", async () => { 1484 setupSession(["space.atbb.permission.manageCategories"]); 1485 const routes = await loadAdminRoutes(); 1486 const res = await routes.request( 1487 "/admin/structure/boards", 1488 postForm({ name: "", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1489 ); 1490 expect(res.status).toBe(302); 1491 const location = res.headers.get("location") ?? ""; 1492 expect(location).toContain("/admin/structure"); 1493 expect(location).toContain("error="); 1494 }); 1495 1496 it("redirects with ?error= when categoryUri is missing", async () => { 1497 setupSession(["space.atbb.permission.manageCategories"]); 1498 const routes = await loadAdminRoutes(); 1499 const res = await routes.request( 1500 "/admin/structure/boards", 1501 postForm({ name: "General Chat", categoryUri: "" }) 1502 ); 1503 expect(res.status).toBe(302); 1504 const location = res.headers.get("location") ?? ""; 1505 expect(location).toContain("error="); 1506 }); 1507 1508 it("redirects with ?error= on AppView error", async () => { 1509 setupSession(["space.atbb.permission.manageCategories"]); 1510 mockFetch.mockResolvedValueOnce( 1511 mockResponse({ error: "Category not found" }, false, 404) 1512 ); 1513 1514 const routes = await loadAdminRoutes(); 1515 const res = await routes.request( 1516 "/admin/structure/boards", 1517 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1518 ); 1519 1520 expect(res.status).toBe(302); 1521 const location = res.headers.get("location") ?? ""; 1522 expect(location).toContain("error="); 1523 }); 1524 1525 it("redirects with ?error= on network error", async () => { 1526 setupSession(["space.atbb.permission.manageCategories"]); 1527 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1528 1529 const routes = await loadAdminRoutes(); 1530 const res = await routes.request( 1531 "/admin/structure/boards", 1532 postForm({ name: "General Chat", categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc" }) 1533 ); 1534 1535 expect(res.status).toBe(302); 1536 const location = res.headers.get("location") ?? ""; 1537 expect(location).toContain("error="); 1538 }); 1539 1540 it("redirects with ?error= for negative sort order", async () => { 1541 setupSession(["space.atbb.permission.manageCategories"]); 1542 const routes = await loadAdminRoutes(); 1543 const res = await routes.request( 1544 "/admin/structure/boards", 1545 postForm({ 1546 name: "General Chat", 1547 categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1548 sortOrder: "-2", 1549 }) 1550 ); 1551 expect(res.status).toBe(302); 1552 const location = res.headers.get("location") ?? ""; 1553 expect(location).toContain("error="); 1554 }); 1555}); 1556 1557describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { 1558 beforeEach(() => { 1559 vi.stubGlobal("fetch", mockFetch); 1560 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1561 vi.resetModules(); 1562 }); 1563 1564 afterEach(() => { 1565 vi.unstubAllGlobals(); 1566 vi.unstubAllEnvs(); 1567 mockFetch.mockReset(); 1568 }); 1569 1570 function mockResponse(body: unknown, ok = true, status = 200) { 1571 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1572 } 1573 1574 function setupSession(permissions: string[]) { 1575 mockFetch.mockResolvedValueOnce( 1576 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1577 ); 1578 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1579 } 1580 1581 async function loadAdminRoutes() { 1582 const { createAdminRoutes } = await import("../admin.js"); 1583 return createAdminRoutes("http://localhost:3000"); 1584 } 1585 1586 function postForm(body: Record<string, string>) { 1587 const params = new URLSearchParams(body); 1588 return { 1589 method: "POST", 1590 headers: { 1591 cookie: "atbb_session=token", 1592 "content-type": "application/x-www-form-urlencoded", 1593 }, 1594 body: params.toString(), 1595 }; 1596 } 1597 1598 it("redirects to /login when unauthenticated", async () => { 1599 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1600 const routes = await loadAdminRoutes(); 1601 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1602 expect(res.status).toBe(302); 1603 expect(res.headers.get("location")).toBe("/login"); 1604 }); 1605 1606 it("returns 403 without manageCategories", async () => { 1607 setupSession(["space.atbb.permission.manageMembers"]); 1608 const routes = await loadAdminRoutes(); 1609 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1610 expect(res.status).toBe(403); 1611 }); 1612 1613 it("redirects to /admin/structure on success", async () => { 1614 setupSession(["space.atbb.permission.manageCategories"]); 1615 mockFetch.mockResolvedValueOnce(mockResponse({ uri: "at://...", cid: "bafyrei..." }, true, 200)); 1616 1617 const routes = await loadAdminRoutes(); 1618 const res = await routes.request( 1619 "/admin/structure/boards/10/edit", 1620 postForm({ name: "Updated Board", description: "", sortOrder: "3" }) 1621 ); 1622 1623 expect(res.status).toBe(302); 1624 expect(res.headers.get("location")).toBe("/admin/structure"); 1625 }); 1626 1627 it("redirects with ?error= when name is missing", async () => { 1628 setupSession(["space.atbb.permission.manageCategories"]); 1629 const routes = await loadAdminRoutes(); 1630 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "" })); 1631 expect(res.status).toBe(302); 1632 const location = res.headers.get("location") ?? ""; 1633 expect(location).toContain("error="); 1634 }); 1635 1636 it("redirects with ?error= on AppView error", async () => { 1637 setupSession(["space.atbb.permission.manageCategories"]); 1638 mockFetch.mockResolvedValueOnce(mockResponse({ error: "Board not found" }, false, 404)); 1639 const routes = await loadAdminRoutes(); 1640 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1641 expect(res.status).toBe(302); 1642 const location = res.headers.get("location") ?? ""; 1643 expect(location).toContain("error="); 1644 }); 1645 1646 it("redirects with ?error= on network error", async () => { 1647 setupSession(["space.atbb.permission.manageCategories"]); 1648 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1649 const routes = await loadAdminRoutes(); 1650 const res = await routes.request("/admin/structure/boards/10/edit", postForm({ name: "Updated" })); 1651 expect(res.status).toBe(302); 1652 const location = res.headers.get("location") ?? ""; 1653 expect(location).toContain("error="); 1654 }); 1655 1656 it("redirects with ?error= for negative sort order", async () => { 1657 setupSession(["space.atbb.permission.manageCategories"]); 1658 const routes = await loadAdminRoutes(); 1659 const res = await routes.request( 1660 "/admin/structure/boards/10/edit", 1661 postForm({ name: "Updated Board", sortOrder: "-3" }) 1662 ); 1663 expect(res.status).toBe(302); 1664 const location = res.headers.get("location") ?? ""; 1665 expect(location).toContain("error="); 1666 }); 1667}); 1668 1669describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { 1670 beforeEach(() => { 1671 vi.stubGlobal("fetch", mockFetch); 1672 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1673 vi.resetModules(); 1674 }); 1675 1676 afterEach(() => { 1677 vi.unstubAllGlobals(); 1678 vi.unstubAllEnvs(); 1679 mockFetch.mockReset(); 1680 }); 1681 1682 function mockResponse(body: unknown, ok = true, status = 200) { 1683 return { ok, status, statusText: ok ? "OK" : "Error", json: () => Promise.resolve(body) }; 1684 } 1685 1686 function setupSession(permissions: string[]) { 1687 mockFetch.mockResolvedValueOnce( 1688 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1689 ); 1690 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1691 } 1692 1693 async function loadAdminRoutes() { 1694 const { createAdminRoutes } = await import("../admin.js"); 1695 return createAdminRoutes("http://localhost:3000"); 1696 } 1697 1698 function postForm(body: Record<string, string> = {}) { 1699 const params = new URLSearchParams(body); 1700 return { 1701 method: "POST", 1702 headers: { 1703 cookie: "atbb_session=token", 1704 "content-type": "application/x-www-form-urlencoded", 1705 }, 1706 body: params.toString(), 1707 }; 1708 } 1709 1710 it("redirects to /login when unauthenticated", async () => { 1711 mockFetch.mockResolvedValueOnce(mockResponse({ authenticated: false })); 1712 const routes = await loadAdminRoutes(); 1713 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1714 expect(res.status).toBe(302); 1715 expect(res.headers.get("location")).toBe("/login"); 1716 }); 1717 1718 it("returns 403 without manageCategories", async () => { 1719 setupSession(["space.atbb.permission.manageMembers"]); 1720 const routes = await loadAdminRoutes(); 1721 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1722 expect(res.status).toBe(403); 1723 }); 1724 1725 it("redirects to /admin/structure on success", async () => { 1726 setupSession(["space.atbb.permission.manageCategories"]); 1727 mockFetch.mockResolvedValueOnce(mockResponse({}, true, 200)); 1728 1729 const routes = await loadAdminRoutes(); 1730 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1731 1732 expect(res.status).toBe(302); 1733 expect(res.headers.get("location")).toBe("/admin/structure"); 1734 }); 1735 1736 it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => { 1737 setupSession(["space.atbb.permission.manageCategories"]); 1738 mockFetch.mockResolvedValueOnce( 1739 mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409) 1740 ); 1741 1742 const routes = await loadAdminRoutes(); 1743 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1744 1745 expect(res.status).toBe(302); 1746 const location = res.headers.get("location") ?? ""; 1747 expect(decodeURIComponent(location)).toContain("Cannot delete board with posts"); 1748 }); 1749 1750 it("redirects with ?error= on network error", async () => { 1751 setupSession(["space.atbb.permission.manageCategories"]); 1752 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1753 1754 const routes = await loadAdminRoutes(); 1755 const res = await routes.request("/admin/structure/boards/10/delete", postForm()); 1756 1757 expect(res.status).toBe(302); 1758 const location = res.headers.get("location") ?? ""; 1759 expect(location).toContain("error="); 1760 }); 1761}); 1762 1763describe("createAdminRoutes — GET /admin/modlog", () => { 1764 beforeEach(() => { 1765 vi.stubGlobal("fetch", mockFetch); 1766 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1767 vi.resetModules(); 1768 }); 1769 1770 afterEach(() => { 1771 vi.unstubAllGlobals(); 1772 vi.unstubAllEnvs(); 1773 mockFetch.mockReset(); 1774 }); 1775 1776 function mockResponse(body: unknown, ok = true, status = 200) { 1777 return { 1778 ok, 1779 status, 1780 statusText: ok ? "OK" : "Error", 1781 json: () => Promise.resolve(body), 1782 }; 1783 } 1784 1785 function setupSession(permissions: string[]) { 1786 mockFetch.mockResolvedValueOnce( 1787 mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.bsky.social" }) 1788 ); 1789 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1790 } 1791 1792 async function loadAdminRoutes() { 1793 const { createAdminRoutes } = await import("../admin.js"); 1794 return createAdminRoutes("http://localhost:3000"); 1795 } 1796 1797 const SAMPLE_ACTIONS = [ 1798 { 1799 id: "1", 1800 action: "space.atbb.modAction.ban", 1801 moderatorDid: "did:plc:alice", 1802 moderatorHandle: "alice.bsky.social", 1803 subjectDid: "did:plc:bob", 1804 subjectHandle: "bob.bsky.social", 1805 subjectPostUri: null, 1806 reason: "Spam", 1807 createdAt: "2026-02-26T12:01:00.000Z", 1808 }, 1809 { 1810 id: "2", 1811 action: "space.atbb.modAction.delete", 1812 moderatorDid: "did:plc:alice", 1813 moderatorHandle: "alice.bsky.social", 1814 subjectDid: null, 1815 subjectHandle: null, 1816 subjectPostUri: "at://did:plc:bob/space.atbb.post/abc123", 1817 reason: "Inappropriate", 1818 createdAt: "2026-02-26T11:30:00.000Z", 1819 }, 1820 ]; 1821 1822 // ── Auth & permission gates ────────────────────────────────────────────── 1823 1824 it("redirects unauthenticated users to /login", async () => { 1825 const routes = await loadAdminRoutes(); 1826 const res = await routes.request("/admin/modlog"); 1827 expect(res.status).toBe(302); 1828 expect(res.headers.get("location")).toBe("/login"); 1829 }); 1830 1831 it("returns 403 for user without any mod permission", async () => { 1832 setupSession(["space.atbb.permission.manageCategories"]); 1833 const routes = await loadAdminRoutes(); 1834 const res = await routes.request("/admin/modlog", { 1835 headers: { cookie: "atbb_session=token" }, 1836 }); 1837 expect(res.status).toBe(403); 1838 const html = await res.text(); 1839 expect(html).toContain("permission"); 1840 }); 1841 1842 it("allows access for moderatePosts permission", async () => { 1843 setupSession(["space.atbb.permission.moderatePosts"]); 1844 mockFetch.mockResolvedValueOnce( 1845 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1846 ); 1847 const routes = await loadAdminRoutes(); 1848 const res = await routes.request("/admin/modlog", { 1849 headers: { cookie: "atbb_session=token" }, 1850 }); 1851 expect(res.status).toBe(200); 1852 }); 1853 1854 it("allows access for banUsers permission", async () => { 1855 setupSession(["space.atbb.permission.banUsers"]); 1856 mockFetch.mockResolvedValueOnce( 1857 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1858 ); 1859 const routes = await loadAdminRoutes(); 1860 const res = await routes.request("/admin/modlog", { 1861 headers: { cookie: "atbb_session=token" }, 1862 }); 1863 expect(res.status).toBe(200); 1864 }); 1865 1866 it("allows access for lockTopics permission", async () => { 1867 setupSession(["space.atbb.permission.lockTopics"]); 1868 mockFetch.mockResolvedValueOnce( 1869 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1870 ); 1871 const routes = await loadAdminRoutes(); 1872 const res = await routes.request("/admin/modlog", { 1873 headers: { cookie: "atbb_session=token" }, 1874 }); 1875 expect(res.status).toBe(200); 1876 }); 1877 1878 // ── Table rendering ────────────────────────────────────────────────────── 1879 1880 it("renders table with moderator handle and action label", async () => { 1881 setupSession(["space.atbb.permission.banUsers"]); 1882 mockFetch.mockResolvedValueOnce( 1883 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1884 ); 1885 const routes = await loadAdminRoutes(); 1886 const res = await routes.request("/admin/modlog", { 1887 headers: { cookie: "atbb_session=token" }, 1888 }); 1889 const html = await res.text(); 1890 expect(html).toContain("alice.bsky.social"); 1891 expect(html).toContain("Ban"); 1892 expect(html).toContain("bob.bsky.social"); 1893 expect(html).toContain("Spam"); 1894 }); 1895 1896 it("maps space.atbb.modAction.delete to 'Hide' label", async () => { 1897 setupSession(["space.atbb.permission.moderatePosts"]); 1898 mockFetch.mockResolvedValueOnce( 1899 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1900 ); 1901 const routes = await loadAdminRoutes(); 1902 const res = await routes.request("/admin/modlog", { 1903 headers: { cookie: "atbb_session=token" }, 1904 }); 1905 const html = await res.text(); 1906 expect(html).toContain("Hide"); 1907 }); 1908 1909 it("shows post URI in subject column for post-targeting actions", async () => { 1910 setupSession(["space.atbb.permission.moderatePosts"]); 1911 mockFetch.mockResolvedValueOnce( 1912 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1913 ); 1914 const routes = await loadAdminRoutes(); 1915 const res = await routes.request("/admin/modlog", { 1916 headers: { cookie: "atbb_session=token" }, 1917 }); 1918 const html = await res.text(); 1919 expect(html).toContain("at://did:plc:bob/space.atbb.post/abc123"); 1920 }); 1921 1922 it("shows handle in subject column for user-targeting actions", async () => { 1923 setupSession(["space.atbb.permission.banUsers"]); 1924 mockFetch.mockResolvedValueOnce( 1925 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 1926 ); 1927 const routes = await loadAdminRoutes(); 1928 const res = await routes.request("/admin/modlog", { 1929 headers: { cookie: "atbb_session=token" }, 1930 }); 1931 const html = await res.text(); 1932 expect(html).toContain("bob.bsky.social"); 1933 }); 1934 1935 it("shows empty state when no actions", async () => { 1936 setupSession(["space.atbb.permission.banUsers"]); 1937 mockFetch.mockResolvedValueOnce( 1938 mockResponse({ actions: [], total: 0, offset: 0, limit: 50 }) 1939 ); 1940 const routes = await loadAdminRoutes(); 1941 const res = await routes.request("/admin/modlog", { 1942 headers: { cookie: "atbb_session=token" }, 1943 }); 1944 const html = await res.text(); 1945 expect(html).toContain("No moderation actions"); 1946 }); 1947 1948 // ── Pagination ─────────────────────────────────────────────────────────── 1949 1950 it("renders 'Page 1 of 2' indicator for 51 total actions", async () => { 1951 setupSession(["space.atbb.permission.banUsers"]); 1952 mockFetch.mockResolvedValueOnce( 1953 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1954 ); 1955 const routes = await loadAdminRoutes(); 1956 const res = await routes.request("/admin/modlog", { 1957 headers: { cookie: "atbb_session=token" }, 1958 }); 1959 const html = await res.text(); 1960 expect(html).toContain("Page 1 of 2"); 1961 }); 1962 1963 it("shows Next link when more pages exist", async () => { 1964 setupSession(["space.atbb.permission.banUsers"]); 1965 mockFetch.mockResolvedValueOnce( 1966 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 1967 ); 1968 const routes = await loadAdminRoutes(); 1969 const res = await routes.request("/admin/modlog", { 1970 headers: { cookie: "atbb_session=token" }, 1971 }); 1972 const html = await res.text(); 1973 expect(html).toContain('href="/admin/modlog?offset=50"'); 1974 expect(html).toContain("Next"); 1975 }); 1976 1977 it("hides Next link on last page", async () => { 1978 setupSession(["space.atbb.permission.banUsers"]); 1979 mockFetch.mockResolvedValueOnce( 1980 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1981 ); 1982 const routes = await loadAdminRoutes(); 1983 const res = await routes.request("/admin/modlog?offset=50", { 1984 headers: { cookie: "atbb_session=token" }, 1985 }); 1986 const html = await res.text(); 1987 expect(html).not.toContain('href="/admin/modlog?offset=100"'); 1988 }); 1989 1990 it("shows Previous link when not on first page", async () => { 1991 setupSession(["space.atbb.permission.banUsers"]); 1992 mockFetch.mockResolvedValueOnce( 1993 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 50, limit: 50 }) 1994 ); 1995 const routes = await loadAdminRoutes(); 1996 const res = await routes.request("/admin/modlog?offset=50", { 1997 headers: { cookie: "atbb_session=token" }, 1998 }); 1999 const html = await res.text(); 2000 expect(html).toContain('href="/admin/modlog?offset=0"'); 2001 expect(html).toContain("Previous"); 2002 }); 2003 2004 it("hides Previous link on first page", async () => { 2005 setupSession(["space.atbb.permission.banUsers"]); 2006 mockFetch.mockResolvedValueOnce( 2007 mockResponse({ actions: SAMPLE_ACTIONS, total: 51, offset: 0, limit: 50 }) 2008 ); 2009 const routes = await loadAdminRoutes(); 2010 const res = await routes.request("/admin/modlog", { 2011 headers: { cookie: "atbb_session=token" }, 2012 }); 2013 const html = await res.text(); 2014 expect(html).not.toContain('href="/admin/modlog?offset=-50"'); 2015 expect(html).not.toContain("Previous"); 2016 }); 2017 2018 it("passes offset query param to AppView", async () => { 2019 setupSession(["space.atbb.permission.banUsers"]); 2020 mockFetch.mockResolvedValueOnce( 2021 mockResponse({ actions: SAMPLE_ACTIONS, total: 100, offset: 50, limit: 50 }) 2022 ); 2023 const routes = await loadAdminRoutes(); 2024 await routes.request("/admin/modlog?offset=50", { 2025 headers: { cookie: "atbb_session=token" }, 2026 }); 2027 // Third fetch call (index 2) is the modlog API call 2028 const modlogCall = mockFetch.mock.calls[2]; 2029 expect(modlogCall[0]).toContain("offset=50"); 2030 expect(modlogCall[0]).toContain("limit=50"); 2031 }); 2032 2033 it("ignores invalid offset and defaults to 0", async () => { 2034 setupSession(["space.atbb.permission.banUsers"]); 2035 mockFetch.mockResolvedValueOnce( 2036 mockResponse({ actions: SAMPLE_ACTIONS, total: 2, offset: 0, limit: 50 }) 2037 ); 2038 const routes = await loadAdminRoutes(); 2039 const res = await routes.request("/admin/modlog?offset=notanumber", { 2040 headers: { cookie: "atbb_session=token" }, 2041 }); 2042 expect(res.status).toBe(200); 2043 const modlogCall = mockFetch.mock.calls[2]; 2044 expect(modlogCall[0]).toContain("offset=0"); 2045 }); 2046 2047 // ── Error handling ─────────────────────────────────────────────────────── 2048 2049 it("returns 503 on AppView network error", async () => { 2050 setupSession(["space.atbb.permission.banUsers"]); 2051 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2052 const routes = await loadAdminRoutes(); 2053 const res = await routes.request("/admin/modlog", { 2054 headers: { cookie: "atbb_session=token" }, 2055 }); 2056 expect(res.status).toBe(503); 2057 const html = await res.text(); 2058 expect(html).toContain("error-display"); 2059 }); 2060 2061 it("returns 500 on AppView server error", async () => { 2062 setupSession(["space.atbb.permission.banUsers"]); 2063 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 2064 const routes = await loadAdminRoutes(); 2065 const res = await routes.request("/admin/modlog", { 2066 headers: { cookie: "atbb_session=token" }, 2067 }); 2068 expect(res.status).toBe(500); 2069 const html = await res.text(); 2070 expect(html).toContain("error-display"); 2071 }); 2072 2073 it("returns 500 when AppView returns non-JSON response body", async () => { 2074 setupSession(["space.atbb.permission.banUsers"]); 2075 mockFetch.mockResolvedValueOnce({ 2076 ok: true, 2077 status: 200, 2078 statusText: "OK", 2079 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2080 }); 2081 const routes = await loadAdminRoutes(); 2082 const res = await routes.request("/admin/modlog", { 2083 headers: { cookie: "atbb_session=token" }, 2084 }); 2085 expect(res.status).toBe(500); 2086 const html = await res.text(); 2087 expect(html).toContain("error-display"); 2088 }); 2089 2090 it("redirects to /login when AppView returns 401", async () => { 2091 setupSession(["space.atbb.permission.banUsers"]); 2092 mockFetch.mockResolvedValueOnce(mockResponse({}, false, 401)); 2093 const routes = await loadAdminRoutes(); 2094 const res = await routes.request("/admin/modlog", { 2095 headers: { cookie: "atbb_session=token" }, 2096 }); 2097 expect(res.status).toBe(302); 2098 expect(res.headers.get("location")).toBe("/login"); 2099 }); 2100}); 2101 2102describe("createAdminRoutes — GET /admin/themes", () => { 2103 beforeEach(() => { 2104 vi.stubGlobal("fetch", mockFetch); 2105 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2106 vi.resetModules(); 2107 }); 2108 2109 afterEach(() => { 2110 vi.unstubAllGlobals(); 2111 vi.unstubAllEnvs(); 2112 mockFetch.mockReset(); 2113 }); 2114 2115 function mockResponse(body: unknown, ok = true, status = 200) { 2116 return { 2117 ok, 2118 status, 2119 statusText: ok ? "OK" : "Error", 2120 json: () => Promise.resolve(body), 2121 }; 2122 } 2123 2124 function setupAuthenticatedSession(permissions: string[]) { 2125 mockFetch.mockResolvedValueOnce( 2126 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2127 ); 2128 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2129 } 2130 2131 async function loadAdminRoutes() { 2132 const { createAdminRoutes } = await import("../admin.js"); 2133 return createAdminRoutes("http://localhost:3000"); 2134 } 2135 2136 it("redirects unauthenticated users to /login", async () => { 2137 const routes = await loadAdminRoutes(); 2138 const res = await routes.request("/admin/themes"); 2139 expect(res.status).toBe(302); 2140 expect(res.headers.get("location")).toBe("/login"); 2141 }); 2142 2143 it("returns 403 for users without manageThemes permission", async () => { 2144 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2145 const routes = await loadAdminRoutes(); 2146 const res = await routes.request("/admin/themes", { 2147 headers: { cookie: "atbb_session=token" }, 2148 }); 2149 expect(res.status).toBe(403); 2150 }); 2151 2152 it("renders theme cards with name, colorScheme badge, and swatches", async () => { 2153 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2154 // GET /api/admin/themes 2155 mockFetch.mockResolvedValueOnce( 2156 mockResponse({ 2157 themes: [ 2158 { 2159 id: "1", 2160 uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2161 name: "Neobrutal Light", 2162 colorScheme: "light", 2163 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00", "color-surface": "#ffffff", "color-secondary": "#3a86ff", "color-border": "#1a1a1a" }, 2164 cssOverrides: null, 2165 fontUrls: null, 2166 createdAt: "2026-01-01T00:00:00.000Z", 2167 indexedAt: "2026-01-01T00:00:00.000Z", 2168 }, 2169 ], 2170 }) 2171 ); 2172 // GET /api/theme-policy 2173 mockFetch.mockResolvedValueOnce( 2174 mockResponse({ 2175 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2176 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", 2177 allowUserChoice: true, 2178 availableThemes: [ 2179 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", cid: "bafytheme1" }, 2180 ], 2181 }) 2182 ); 2183 2184 const routes = await loadAdminRoutes(); 2185 const res = await routes.request("/admin/themes", { 2186 headers: { cookie: "atbb_session=token" }, 2187 }); 2188 expect(res.status).toBe(200); 2189 const html = await res.text(); 2190 expect(html).toContain("Neobrutal Light"); 2191 expect(html).toContain("light"); // colorScheme badge 2192 expect(html).toContain("#f5f0e8"); // color-bg swatch 2193 expect(html).toContain("#ff5c00"); // color-primary swatch 2194 expect(html).toContain("policy-form"); // policy form id 2195 expect(html).toContain("availableThemes"); // checkbox name 2196 }); 2197 2198 it("shows error banner when ?error= query param is present", async () => { 2199 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2200 mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 2201 mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); // no policy yet 2202 2203 const routes = await loadAdminRoutes(); 2204 const res = await routes.request( 2205 "/admin/themes?error=" + encodeURIComponent("Cannot delete a default theme"), 2206 { headers: { cookie: "atbb_session=token" } } 2207 ); 2208 expect(res.status).toBe(200); 2209 const html = await res.text(); 2210 expect(html).toContain("Cannot delete a default theme"); 2211 expect(html).toContain("structure-error-banner"); 2212 }); 2213 2214 it("renders create form with preset options", async () => { 2215 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2216 mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] })); 2217 mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); 2218 2219 const routes = await loadAdminRoutes(); 2220 const res = await routes.request("/admin/themes", { 2221 headers: { cookie: "atbb_session=token" }, 2222 }); 2223 expect(res.status).toBe(200); 2224 const html = await res.text(); 2225 expect(html).toContain("neobrutal-light"); 2226 expect(html).toContain("neobrutal-dark"); 2227 expect(html).toContain("blank"); 2228 }); 2229 2230 it("renders page gracefully when AppView returns non-JSON response", async () => { 2231 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2232 // AppView returns an HTML error page — .json() throws SyntaxError 2233 mockFetch.mockResolvedValueOnce({ 2234 ok: true, 2235 status: 200, 2236 statusText: "OK", 2237 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2238 }); 2239 mockFetch.mockResolvedValueOnce({ 2240 ok: true, 2241 status: 200, 2242 statusText: "OK", 2243 json: () => Promise.reject(new SyntaxError("Unexpected token '<' in JSON")), 2244 }); 2245 2246 const routes = await loadAdminRoutes(); 2247 const res = await routes.request("/admin/themes", { 2248 headers: { cookie: "atbb_session=token" }, 2249 }); 2250 // Should render the page with empty data rather than crashing with 500 2251 expect(res.status).toBe(200); 2252 const html = await res.text(); 2253 expect(html).toContain("No themes yet"); 2254 }); 2255}); 2256 2257describe("createAdminRoutes — POST /admin/themes", () => { 2258 beforeEach(() => { 2259 vi.stubGlobal("fetch", mockFetch); 2260 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2261 vi.resetModules(); 2262 }); 2263 2264 afterEach(() => { 2265 vi.unstubAllGlobals(); 2266 vi.unstubAllEnvs(); 2267 mockFetch.mockReset(); 2268 }); 2269 2270 function mockResponse(body: unknown, ok = true, status = 200) { 2271 return { ok, status, json: () => Promise.resolve(body) }; 2272 } 2273 2274 function setupAuthenticatedSession(permissions: string[]) { 2275 mockFetch.mockResolvedValueOnce( 2276 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2277 ); 2278 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2279 } 2280 2281 async function loadAdminRoutes() { 2282 const { createAdminRoutes } = await import("../admin.js"); 2283 return createAdminRoutes("http://localhost:3000"); 2284 } 2285 2286 it("redirects to /login when unauthenticated", async () => { 2287 mockFetch.mockResolvedValueOnce( 2288 mockResponse({ authenticated: false, did: null, handle: null }) 2289 ); 2290 const routes = await loadAdminRoutes(); 2291 const res = await routes.request("/admin/themes", { 2292 method: "POST", 2293 headers: { "content-type": "application/x-www-form-urlencoded" }, 2294 body: "name=Test&colorScheme=light&preset=blank", 2295 }); 2296 expect(res.status).toBe(302); 2297 expect(res.headers.get("location")).toBe("/login"); 2298 }); 2299 2300 it("returns 403 without manageThemes permission", async () => { 2301 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2302 const routes = await loadAdminRoutes(); 2303 const res = await routes.request("/admin/themes", { 2304 method: "POST", 2305 headers: { 2306 cookie: "atbb_session=token", 2307 "content-type": "application/x-www-form-urlencoded", 2308 }, 2309 body: "name=Test&colorScheme=light&preset=blank", 2310 }); 2311 expect(res.status).toBe(403); 2312 }); 2313 2314 it("redirects with error on network failure", async () => { 2315 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2316 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2317 2318 const routes = await loadAdminRoutes(); 2319 const res = await routes.request("/admin/themes", { 2320 method: "POST", 2321 headers: { 2322 cookie: "atbb_session=token", 2323 "content-type": "application/x-www-form-urlencoded", 2324 }, 2325 body: "name=My+Theme&colorScheme=light&preset=blank", 2326 }); 2327 2328 expect(res.status).toBe(302); 2329 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2330 }); 2331 2332 it("creates theme and redirects to /admin/themes on success", async () => { 2333 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2334 mockFetch.mockResolvedValueOnce( 2335 mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.theme/newrkey", cid: "bafy" }, true, 201) 2336 ); 2337 2338 const routes = await loadAdminRoutes(); 2339 const res = await routes.request("/admin/themes", { 2340 method: "POST", 2341 headers: { 2342 cookie: "atbb_session=token", 2343 "content-type": "application/x-www-form-urlencoded", 2344 }, 2345 body: "name=My+Theme&colorScheme=light&preset=neobrutal-light", 2346 }); 2347 2348 expect(res.status).toBe(302); 2349 expect(res.headers.get("location")).toBe("/admin/themes"); 2350 }); 2351 2352 it("sends preset tokens to API when preset is neobrutal-light", async () => { 2353 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2354 mockFetch.mockResolvedValueOnce( 2355 mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 2356 ); 2357 2358 const routes = await loadAdminRoutes(); 2359 await routes.request("/admin/themes", { 2360 method: "POST", 2361 headers: { 2362 cookie: "atbb_session=token", 2363 "content-type": "application/x-www-form-urlencoded", 2364 }, 2365 body: "name=Neo&colorScheme=light&preset=neobrutal-light", 2366 }); 2367 2368 // The API call should contain the preset tokens 2369 const apiCall = mockFetch.mock.calls[2]; // calls 0+1 = auth, call 2 = POST /api/admin/themes 2370 const body = JSON.parse(apiCall[1].body); 2371 expect(body.tokens).toHaveProperty("color-bg"); 2372 expect(body.tokens["color-bg"]).toBe("#f5f0e8"); 2373 expect(body.name).toBe("Neo"); 2374 expect(body.colorScheme).toBe("light"); 2375 }); 2376 2377 it("sends empty tokens for blank preset", async () => { 2378 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2379 mockFetch.mockResolvedValueOnce( 2380 mockResponse({ uri: "at://...", cid: "bafy" }, true, 201) 2381 ); 2382 2383 const routes = await loadAdminRoutes(); 2384 await routes.request("/admin/themes", { 2385 method: "POST", 2386 headers: { 2387 cookie: "atbb_session=token", 2388 "content-type": "application/x-www-form-urlencoded", 2389 }, 2390 body: "name=Blank+Theme&colorScheme=light&preset=blank", 2391 }); 2392 2393 const apiCall = mockFetch.mock.calls[2]; 2394 const body = JSON.parse(apiCall[1].body); 2395 expect(body.tokens).toEqual({}); 2396 }); 2397 2398 it("redirects with error when name is missing", async () => { 2399 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2400 2401 const routes = await loadAdminRoutes(); 2402 const res = await routes.request("/admin/themes", { 2403 method: "POST", 2404 headers: { 2405 cookie: "atbb_session=token", 2406 "content-type": "application/x-www-form-urlencoded", 2407 }, 2408 body: "colorScheme=light&preset=blank", 2409 }); 2410 2411 expect(res.status).toBe(302); 2412 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2413 expect(res.headers.get("location")).toContain("required"); 2414 }); 2415 2416 it("redirects with error on AppView API failure", async () => { 2417 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2418 mockFetch.mockResolvedValueOnce( 2419 mockResponse({ error: "Theme creation failed" }, false, 500) 2420 ); 2421 2422 const routes = await loadAdminRoutes(); 2423 const res = await routes.request("/admin/themes", { 2424 method: "POST", 2425 headers: { 2426 cookie: "atbb_session=token", 2427 "content-type": "application/x-www-form-urlencoded", 2428 }, 2429 body: "name=My+Theme&colorScheme=light&preset=blank", 2430 }); 2431 2432 expect(res.status).toBe(302); 2433 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2434 }); 2435}); 2436 2437describe("createAdminRoutes — POST /admin/themes/:rkey/duplicate", () => { 2438 beforeEach(() => { 2439 vi.stubGlobal("fetch", mockFetch); 2440 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2441 vi.resetModules(); 2442 }); 2443 2444 afterEach(() => { 2445 vi.unstubAllGlobals(); 2446 vi.unstubAllEnvs(); 2447 mockFetch.mockReset(); 2448 }); 2449 2450 function mockResponse(body: unknown, ok = true, status = 200) { 2451 return { ok, status, json: () => Promise.resolve(body) }; 2452 } 2453 2454 function setupAuthenticatedSession(permissions: string[]) { 2455 mockFetch.mockResolvedValueOnce( 2456 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2457 ); 2458 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2459 } 2460 2461 async function loadAdminRoutes() { 2462 const { createAdminRoutes } = await import("../admin.js"); 2463 return createAdminRoutes("http://localhost:3000"); 2464 } 2465 2466 it("redirects to /login when unauthenticated", async () => { 2467 mockFetch.mockResolvedValueOnce( 2468 mockResponse({ authenticated: false, did: null, handle: null }) 2469 ); 2470 const routes = await loadAdminRoutes(); 2471 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2472 method: "POST", 2473 headers: { "content-type": "application/x-www-form-urlencoded" }, 2474 }); 2475 expect(res.status).toBe(302); 2476 expect(res.headers.get("location")).toBe("/login"); 2477 }); 2478 2479 it("returns 403 without manageThemes permission", async () => { 2480 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2481 const routes = await loadAdminRoutes(); 2482 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2483 method: "POST", 2484 headers: { 2485 cookie: "atbb_session=token", 2486 "content-type": "application/x-www-form-urlencoded", 2487 }, 2488 }); 2489 expect(res.status).toBe(403); 2490 }); 2491 2492 it("redirects with error on network failure", async () => { 2493 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2494 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2495 2496 const routes = await loadAdminRoutes(); 2497 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2498 method: "POST", 2499 headers: { cookie: "atbb_session=token" }, 2500 }); 2501 2502 expect(res.status).toBe(302); 2503 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2504 }); 2505 2506 it("duplicates theme and redirects to /admin/themes on success", async () => { 2507 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2508 mockFetch.mockResolvedValueOnce( 2509 mockResponse( 2510 { uri: "at://...", rkey: "newrkey", name: "Neobrutal Light (Copy)" }, 2511 true, 2512 201 2513 ) 2514 ); 2515 2516 const routes = await loadAdminRoutes(); 2517 const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", { 2518 method: "POST", 2519 headers: { cookie: "atbb_session=token" }, 2520 }); 2521 2522 expect(res.status).toBe(302); 2523 expect(res.headers.get("location")).toBe("/admin/themes"); 2524 }); 2525 2526 it("redirects with error on AppView failure", async () => { 2527 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2528 mockFetch.mockResolvedValueOnce( 2529 mockResponse({ error: "Theme not found" }, false, 404) 2530 ); 2531 2532 const routes = await loadAdminRoutes(); 2533 const res = await routes.request("/admin/themes/nonexistent/duplicate", { 2534 method: "POST", 2535 headers: { cookie: "atbb_session=token" }, 2536 }); 2537 2538 expect(res.status).toBe(302); 2539 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2540 }); 2541}); 2542 2543describe("createAdminRoutes — POST /admin/themes/:rkey/delete", () => { 2544 beforeEach(() => { 2545 vi.stubGlobal("fetch", mockFetch); 2546 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2547 vi.resetModules(); 2548 }); 2549 2550 afterEach(() => { 2551 vi.unstubAllGlobals(); 2552 vi.unstubAllEnvs(); 2553 mockFetch.mockReset(); 2554 }); 2555 2556 function mockResponse(body: unknown, ok = true, status = 200) { 2557 return { ok, status, json: () => Promise.resolve(body) }; 2558 } 2559 2560 function setupAuthenticatedSession(permissions: string[]) { 2561 mockFetch.mockResolvedValueOnce( 2562 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2563 ); 2564 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2565 } 2566 2567 async function loadAdminRoutes() { 2568 const { createAdminRoutes } = await import("../admin.js"); 2569 return createAdminRoutes("http://localhost:3000"); 2570 } 2571 2572 it("redirects to /login when unauthenticated", async () => { 2573 mockFetch.mockResolvedValueOnce( 2574 mockResponse({ authenticated: false, did: null, handle: null }) 2575 ); 2576 const routes = await loadAdminRoutes(); 2577 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2578 method: "POST", 2579 headers: { "content-type": "application/x-www-form-urlencoded" }, 2580 }); 2581 expect(res.status).toBe(302); 2582 expect(res.headers.get("location")).toBe("/login"); 2583 }); 2584 2585 it("returns 403 without manageThemes permission", async () => { 2586 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2587 const routes = await loadAdminRoutes(); 2588 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2589 method: "POST", 2590 headers: { 2591 cookie: "atbb_session=token", 2592 "content-type": "application/x-www-form-urlencoded", 2593 }, 2594 }); 2595 expect(res.status).toBe(403); 2596 }); 2597 2598 it("redirects with error on network failure", async () => { 2599 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2600 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2601 2602 const routes = await loadAdminRoutes(); 2603 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2604 method: "POST", 2605 headers: { cookie: "atbb_session=token" }, 2606 }); 2607 2608 expect(res.status).toBe(302); 2609 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2610 }); 2611 2612 it("deletes theme and redirects to /admin/themes on success", async () => { 2613 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2614 mockFetch.mockResolvedValueOnce(mockResponse({ deleted: true }, true, 200)); 2615 2616 const routes = await loadAdminRoutes(); 2617 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2618 method: "POST", 2619 headers: { cookie: "atbb_session=token" }, 2620 }); 2621 2622 expect(res.status).toBe(302); 2623 expect(res.headers.get("location")).toBe("/admin/themes"); 2624 }); 2625 2626 it("redirects with human-friendly error message on 409 conflict (theme is a default)", async () => { 2627 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2628 mockFetch.mockResolvedValueOnce( 2629 mockResponse( 2630 { error: "Cannot delete a theme that is currently set as a default" }, 2631 false, 2632 409 2633 ) 2634 ); 2635 2636 const routes = await loadAdminRoutes(); 2637 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2638 method: "POST", 2639 headers: { cookie: "atbb_session=token" }, 2640 }); 2641 2642 expect(res.status).toBe(302); 2643 const location = res.headers.get("location") ?? ""; 2644 expect(location).toContain("/admin/themes?error="); 2645 expect(decodeURIComponent(location)).toContain("Cannot delete"); 2646 }); 2647 2648 it("redirects with error on generic AppView failure", async () => { 2649 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2650 mockFetch.mockResolvedValueOnce( 2651 mockResponse({ error: "Internal server error" }, false, 500) 2652 ); 2653 2654 const routes = await loadAdminRoutes(); 2655 const res = await routes.request("/admin/themes/3lbltheme1aa/delete", { 2656 method: "POST", 2657 headers: { cookie: "atbb_session=token" }, 2658 }); 2659 2660 expect(res.status).toBe(302); 2661 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2662 }); 2663}); 2664 2665describe("createAdminRoutes — POST /admin/theme-policy", () => { 2666 beforeEach(() => { 2667 vi.stubGlobal("fetch", mockFetch); 2668 vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 2669 vi.resetModules(); 2670 }); 2671 2672 afterEach(() => { 2673 vi.unstubAllGlobals(); 2674 vi.unstubAllEnvs(); 2675 mockFetch.mockReset(); 2676 }); 2677 2678 function mockResponse(body: unknown, ok = true, status = 200) { 2679 return { ok, status, json: () => Promise.resolve(body) }; 2680 } 2681 2682 function setupAuthenticatedSession(permissions: string[]) { 2683 mockFetch.mockResolvedValueOnce( 2684 mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 2685 ); 2686 mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 2687 } 2688 2689 async function loadAdminRoutes() { 2690 const { createAdminRoutes } = await import("../admin.js"); 2691 return createAdminRoutes("http://localhost:3000"); 2692 } 2693 2694 it("redirects to /login when unauthenticated", async () => { 2695 mockFetch.mockResolvedValueOnce( 2696 mockResponse({ authenticated: false, did: null, handle: null }) 2697 ); 2698 const routes = await loadAdminRoutes(); 2699 const res = await routes.request("/admin/theme-policy", { 2700 method: "POST", 2701 headers: { "content-type": "application/x-www-form-urlencoded" }, 2702 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2703 }); 2704 expect(res.status).toBe(302); 2705 expect(res.headers.get("location")).toBe("/login"); 2706 }); 2707 2708 it("returns 403 without manageThemes permission", async () => { 2709 setupAuthenticatedSession(["space.atbb.permission.manageMembers"]); 2710 const routes = await loadAdminRoutes(); 2711 const res = await routes.request("/admin/theme-policy", { 2712 method: "POST", 2713 headers: { 2714 cookie: "atbb_session=token", 2715 "content-type": "application/x-www-form-urlencoded", 2716 }, 2717 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2718 }); 2719 expect(res.status).toBe(403); 2720 }); 2721 2722 it("redirects with error on network failure", async () => { 2723 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2724 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 2725 2726 const routes = await loadAdminRoutes(); 2727 const res = await routes.request("/admin/theme-policy", { 2728 method: "POST", 2729 headers: { 2730 cookie: "atbb_session=token", 2731 "content-type": "application/x-www-form-urlencoded", 2732 }, 2733 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2734 }); 2735 2736 expect(res.status).toBe(302); 2737 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2738 }); 2739 2740 it("saves policy and redirects to /admin/themes on success", async () => { 2741 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2742 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2743 2744 const routes = await loadAdminRoutes(); 2745 const body = new URLSearchParams({ 2746 defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1", 2747 defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2", 2748 allowUserChoice: "on", 2749 }); 2750 body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1"); 2751 body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2"); 2752 2753 const res = await routes.request("/admin/theme-policy", { 2754 method: "POST", 2755 headers: { 2756 cookie: "atbb_session=token", 2757 "content-type": "application/x-www-form-urlencoded", 2758 }, 2759 body: body.toString(), 2760 }); 2761 2762 expect(res.status).toBe(302); 2763 expect(res.headers.get("location")).toBe("/admin/themes"); 2764 2765 const apiCall = mockFetch.mock.calls[2]; 2766 const sentBody = JSON.parse(apiCall[1].body); 2767 expect(sentBody.allowUserChoice).toBe(true); 2768 expect(sentBody.availableThemes).toEqual([ 2769 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1" }, 2770 { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2" }, 2771 ]); 2772 }); 2773 2774 it("treats absent allowUserChoice checkbox as false", async () => { 2775 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2776 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2777 2778 const routes = await loadAdminRoutes(); 2779 // No allowUserChoice field — checkbox was unchecked 2780 const res = await routes.request("/admin/theme-policy", { 2781 method: "POST", 2782 headers: { 2783 cookie: "atbb_session=token", 2784 "content-type": "application/x-www-form-urlencoded", 2785 }, 2786 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test", 2787 }); 2788 2789 expect(res.status).toBe(302); 2790 const apiCall = mockFetch.mock.calls[2]; 2791 const sentBody = JSON.parse(apiCall[1].body); 2792 expect(sentBody.allowUserChoice).toBe(false); 2793 }); 2794 2795 it("sends empty availableThemes when no checkboxes are checked", async () => { 2796 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2797 mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200)); 2798 2799 const routes = await loadAdminRoutes(); 2800 const res = await routes.request("/admin/theme-policy", { 2801 method: "POST", 2802 headers: { 2803 cookie: "atbb_session=token", 2804 "content-type": "application/x-www-form-urlencoded", 2805 }, 2806 body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test&allowUserChoice=on", 2807 }); 2808 2809 expect(res.status).toBe(302); 2810 const apiCall = mockFetch.mock.calls[2]; 2811 const sentBody = JSON.parse(apiCall[1].body); 2812 expect(sentBody.availableThemes).toEqual([]); 2813 }); 2814 2815 it("redirects with error on AppView failure", async () => { 2816 setupAuthenticatedSession(["space.atbb.permission.manageThemes"]); 2817 mockFetch.mockResolvedValueOnce( 2818 mockResponse({ error: "Invalid theme URIs" }, false, 400) 2819 ); 2820 2821 const routes = await loadAdminRoutes(); 2822 const res = await routes.request("/admin/theme-policy", { 2823 method: "POST", 2824 headers: { 2825 cookie: "atbb_session=token", 2826 "content-type": "application/x-www-form-urlencoded", 2827 }, 2828 body: "defaultLightThemeUri=bad&defaultDarkThemeUri=bad", 2829 }); 2830 2831 expect(res.status).toBe(302); 2832 expect(res.headers.get("location")).toContain("/admin/themes?error="); 2833 }); 2834});