WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at root/atb-56-theme-caching-layer 664 lines 21 kB view raw
1import { describe, it, expect, beforeEach, afterEach } from "vitest"; 2import { Hono } from "hono"; 3import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 4import { roles, rolePermissions, memberships, users } from "@atbb/db"; 5import { 6 checkPermission, 7 checkMinRole, 8 canActOnUser, 9 requireAnyPermission, 10} from "../permissions.js"; 11import type { Variables } from "../../types.js"; 12 13describe("Permission Helper Functions", () => { 14 let ctx: TestContext; 15 16 beforeEach(async () => { 17 ctx = await createTestContext(); 18 }); 19 20 afterEach(async () => { 21 await ctx.cleanup(); 22 }); 23 24 describe("checkPermission", () => { 25 it("returns true when user has required permission", async () => { 26 // Create a test role with createTopics permission 27 const [memberRole] = await ctx.db.insert(roles).values({ 28 did: ctx.config.forumDid, 29 rkey: "test-role-123", 30 cid: "test-cid", 31 name: "Member", 32 description: "Test member role", 33 priority: 30, 34 createdAt: new Date(), 35 indexedAt: new Date(), 36 }).returning({ id: roles.id }); 37 38 await ctx.db.insert(rolePermissions).values([ 39 { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 40 ]); 41 42 // Create a test user 43 await ctx.db.insert(users).values({ 44 did: "did:plc:test-testuser", 45 handle: "testuser.bsky.social", 46 indexedAt: new Date(), 47 }); 48 49 // Create membership with roleUri pointing to test role 50 await ctx.db.insert(memberships).values({ 51 did: "did:plc:test-testuser", 52 rkey: "membership-123", 53 cid: "test-cid", 54 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 55 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/test-role-123`, 56 createdAt: new Date(), 57 indexedAt: new Date(), 58 }); 59 60 const result = await checkPermission( 61 ctx, 62 "did:plc:test-testuser", 63 "space.atbb.permission.createTopics" 64 ); 65 66 expect(result).toBe(true); 67 }); 68 69 it("returns true for Owner role with wildcard permission", async () => { 70 // Create Owner role with wildcard 71 const [ownerRole] = await ctx.db.insert(roles).values({ 72 did: ctx.config.forumDid, 73 rkey: "owner-role", 74 cid: "test-cid", 75 name: "Owner", 76 description: "Forum owner", 77 priority: 0, 78 createdAt: new Date(), 79 indexedAt: new Date(), 80 }).returning({ id: roles.id }); 81 82 await ctx.db.insert(rolePermissions).values([ 83 { roleId: ownerRole.id, permission: "*" }, 84 ]); 85 86 await ctx.db.insert(users).values({ 87 did: "did:plc:test-owner", 88 handle: "owner.bsky.social", 89 indexedAt: new Date(), 90 }); 91 92 await ctx.db.insert(memberships).values({ 93 did: "did:plc:test-owner", 94 rkey: "membership-123", 95 cid: "test-cid", 96 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 97 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role`, 98 createdAt: new Date(), 99 indexedAt: new Date(), 100 }); 101 102 // Should return true for ANY permission 103 const result = await checkPermission( 104 ctx, 105 "did:plc:test-owner", 106 "space.atbb.permission.someRandomPermission" 107 ); 108 109 expect(result).toBe(true); 110 }); 111 112 it("returns false when user has no role assigned", async () => { 113 await ctx.db.insert(users).values({ 114 did: "did:plc:test-norole", 115 handle: "norole.bsky.social", 116 indexedAt: new Date(), 117 }); 118 119 // Create membership with roleUri = null 120 await ctx.db.insert(memberships).values({ 121 did: "did:plc:test-norole", 122 rkey: "membership-123", 123 cid: "test-cid", 124 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 125 roleUri: null, // No role assigned 126 createdAt: new Date(), 127 indexedAt: new Date(), 128 }); 129 130 const result = await checkPermission( 131 ctx, 132 "did:plc:test-norole", 133 "space.atbb.permission.createTopics" 134 ); 135 136 expect(result).toBe(false); 137 }); 138 139 it("returns false when user's role is deleted (fail closed)", async () => { 140 await ctx.db.insert(users).values({ 141 did: "did:plc:test-deletedrole", 142 handle: "deletedrole.bsky.social", 143 indexedAt: new Date(), 144 }); 145 146 // Create membership with roleUri pointing to non-existent role 147 await ctx.db.insert(memberships).values({ 148 did: "did:plc:test-deletedrole", 149 rkey: "membership-123", 150 cid: "test-cid", 151 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 152 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/deleted-role`, 153 createdAt: new Date(), 154 indexedAt: new Date(), 155 }); 156 157 const result = await checkPermission( 158 ctx, 159 "did:plc:test-deletedrole", 160 "space.atbb.permission.createTopics" 161 ); 162 163 expect(result).toBe(false); // Fail closed 164 }); 165 166 it("returns false when user has no membership", async () => { 167 await ctx.db.insert(users).values({ 168 did: "did:plc:test-nomembership", 169 handle: "nomembership.bsky.social", 170 indexedAt: new Date(), 171 }); 172 173 // No membership record created 174 175 const result = await checkPermission( 176 ctx, 177 "did:plc:test-nomembership", 178 "space.atbb.permission.createTopics" 179 ); 180 181 expect(result).toBe(false); 182 }); 183 }); 184 185 describe("checkMinRole", () => { 186 it("returns true when user has exact role match", async () => { 187 await ctx.db.insert(roles).values({ 188 did: ctx.config.forumDid, 189 rkey: "admin-role", 190 cid: "test-cid", 191 name: "Admin", 192 priority: 10, 193 createdAt: new Date(), 194 indexedAt: new Date(), 195 }); 196 197 await ctx.db.insert(users).values({ 198 did: "did:plc:test-admin", 199 handle: "admin.bsky.social", 200 indexedAt: new Date(), 201 }); 202 203 await ctx.db.insert(memberships).values({ 204 did: "did:plc:test-admin", 205 rkey: "membership-123", 206 cid: "test-cid", 207 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 208 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role`, 209 createdAt: new Date(), 210 indexedAt: new Date(), 211 }); 212 213 const result = await checkMinRole(ctx, "did:plc:test-admin", "admin"); 214 215 expect(result).toBe(true); 216 }); 217 218 it("returns true when user has higher authority role", async () => { 219 // Owner (priority 0) should pass admin check (priority 10) 220 await ctx.db.insert(roles).values({ 221 did: ctx.config.forumDid, 222 rkey: "owner-role-2", 223 cid: "test-cid", 224 name: "Owner", 225 priority: 0, 226 createdAt: new Date(), 227 indexedAt: new Date(), 228 }); 229 230 await ctx.db.insert(users).values({ 231 did: "did:plc:test-owner2", 232 handle: "owner2.bsky.social", 233 indexedAt: new Date(), 234 }); 235 236 await ctx.db.insert(memberships).values({ 237 did: "did:plc:test-owner2", 238 rkey: "membership-owner2", 239 cid: "test-cid", 240 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 241 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner-role-2`, 242 createdAt: new Date(), 243 indexedAt: new Date(), 244 }); 245 246 const result = await checkMinRole(ctx, "did:plc:test-owner2", "admin"); 247 248 expect(result).toBe(true); // Owner > Admin 249 }); 250 251 it("returns false when user has lower authority role", async () => { 252 // Moderator (priority 20) should fail admin check (priority 10) 253 await ctx.db.insert(roles).values({ 254 did: ctx.config.forumDid, 255 rkey: "mod-role", 256 cid: "test-cid", 257 name: "Moderator", 258 priority: 20, 259 createdAt: new Date(), 260 indexedAt: new Date(), 261 }); 262 263 await ctx.db.insert(users).values({ 264 did: "did:plc:test-mod", 265 handle: "mod.bsky.social", 266 indexedAt: new Date(), 267 }); 268 269 await ctx.db.insert(memberships).values({ 270 did: "did:plc:test-mod", 271 rkey: "membership-123", 272 cid: "test-cid", 273 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 274 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role`, 275 createdAt: new Date(), 276 indexedAt: new Date(), 277 }); 278 279 const result = await checkMinRole(ctx, "did:plc:test-mod", "admin"); 280 281 expect(result).toBe(false); // Moderator < Admin 282 }); 283 }); 284 285 describe("canActOnUser", () => { 286 it("returns true when actor is acting on themselves", async () => { 287 const result = await canActOnUser( 288 ctx, 289 "did:plc:test-testuser", 290 "did:plc:test-testuser" // Same DID 291 ); 292 293 expect(result).toBe(true); // Self-action bypass 294 }); 295 296 it("returns true when actor has higher authority", async () => { 297 // Create Admin role (priority 10) 298 await ctx.db.insert(roles).values({ 299 did: ctx.config.forumDid, 300 rkey: "admin-role-2", 301 cid: "test-cid", 302 name: "Admin", 303 priority: 10, 304 createdAt: new Date(), 305 indexedAt: new Date(), 306 }); 307 308 // Create Moderator role (priority 20) 309 await ctx.db.insert(roles).values({ 310 did: ctx.config.forumDid, 311 rkey: "mod-role-2", 312 cid: "test-cid", 313 name: "Moderator", 314 priority: 20, 315 createdAt: new Date(), 316 indexedAt: new Date(), 317 }); 318 319 // Admin user 320 await ctx.db.insert(users).values({ 321 did: "did:plc:test-admin2", 322 handle: "admin2.bsky.social", 323 indexedAt: new Date(), 324 }); 325 326 await ctx.db.insert(memberships).values({ 327 did: "did:plc:test-admin2", 328 rkey: "membership-admin2", 329 cid: "test-cid", 330 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 331 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-2`, 332 createdAt: new Date(), 333 indexedAt: new Date(), 334 }); 335 336 // Moderator user 337 await ctx.db.insert(users).values({ 338 did: "did:plc:test-mod2", 339 handle: "mod2.bsky.social", 340 indexedAt: new Date(), 341 }); 342 343 await ctx.db.insert(memberships).values({ 344 did: "did:plc:test-mod2", 345 rkey: "membership-mod2", 346 cid: "test-cid", 347 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 348 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-2`, 349 createdAt: new Date(), 350 indexedAt: new Date(), 351 }); 352 353 const result = await canActOnUser(ctx, "did:plc:test-admin2", "did:plc:test-mod2"); 354 355 expect(result).toBe(true); // Admin (10) can act on Moderator (20) 356 }); 357 358 it("returns false when actor has equal authority", async () => { 359 // Create Admin role 360 await ctx.db.insert(roles).values({ 361 did: ctx.config.forumDid, 362 rkey: "admin-role-3", 363 cid: "test-cid", 364 name: "Admin", 365 priority: 10, 366 createdAt: new Date(), 367 indexedAt: new Date(), 368 }); 369 370 // Admin user 1 371 await ctx.db.insert(users).values({ 372 did: "did:plc:test-admin3", 373 handle: "admin3.bsky.social", 374 indexedAt: new Date(), 375 }); 376 377 await ctx.db.insert(memberships).values({ 378 did: "did:plc:test-admin3", 379 rkey: "membership-admin3", 380 cid: "test-cid", 381 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 382 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-3`, 383 createdAt: new Date(), 384 indexedAt: new Date(), 385 }); 386 387 // Admin user 2 388 await ctx.db.insert(users).values({ 389 did: "did:plc:test-admin4", 390 handle: "admin4.bsky.social", 391 indexedAt: new Date(), 392 }); 393 394 await ctx.db.insert(memberships).values({ 395 did: "did:plc:test-admin4", 396 rkey: "membership-admin4", 397 cid: "test-cid", 398 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 399 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-3`, 400 createdAt: new Date(), 401 indexedAt: new Date(), 402 }); 403 404 const result = await canActOnUser(ctx, "did:plc:test-admin3", "did:plc:test-admin4"); 405 406 expect(result).toBe(false); // Admin (10) cannot act on Admin (10) 407 }); 408 409 it("returns false when actor has lower authority", async () => { 410 // Create Admin role (priority 10) 411 await ctx.db.insert(roles).values({ 412 did: ctx.config.forumDid, 413 rkey: "admin-role-4", 414 cid: "test-cid", 415 name: "Admin", 416 priority: 10, 417 createdAt: new Date(), 418 indexedAt: new Date(), 419 }); 420 421 // Create Moderator role (priority 20) 422 await ctx.db.insert(roles).values({ 423 did: ctx.config.forumDid, 424 rkey: "mod-role-4", 425 cid: "test-cid", 426 name: "Moderator", 427 priority: 20, 428 createdAt: new Date(), 429 indexedAt: new Date(), 430 }); 431 432 // Admin user 433 await ctx.db.insert(users).values({ 434 did: "did:plc:test-admin5", 435 handle: "admin5.bsky.social", 436 indexedAt: new Date(), 437 }); 438 439 await ctx.db.insert(memberships).values({ 440 did: "did:plc:test-admin5", 441 rkey: "membership-admin5", 442 cid: "test-cid", 443 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 444 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin-role-4`, 445 createdAt: new Date(), 446 indexedAt: new Date(), 447 }); 448 449 // Moderator user 450 await ctx.db.insert(users).values({ 451 did: "did:plc:test-mod5", 452 handle: "mod5.bsky.social", 453 indexedAt: new Date(), 454 }); 455 456 await ctx.db.insert(memberships).values({ 457 did: "did:plc:test-mod5", 458 rkey: "membership-mod5", 459 cid: "test-cid", 460 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 461 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-4`, 462 createdAt: new Date(), 463 indexedAt: new Date(), 464 }); 465 466 const result = await canActOnUser(ctx, "did:plc:test-mod5", "did:plc:test-admin5"); 467 468 expect(result).toBe(false); // Moderator (20) cannot act on Admin (10) 469 }); 470 }); 471 472 describe("requireAnyPermission", () => { 473 it("returns 200 when user has one of the required permissions", async () => { 474 // Create a role with moderatePosts permission 475 const [modRole] = await ctx.db.insert(roles).values({ 476 did: ctx.config.forumDid, 477 rkey: "mod-role-anyperm-1", 478 cid: "test-cid", 479 name: "Moderator", 480 description: "Moderator role", 481 priority: 20, 482 createdAt: new Date(), 483 indexedAt: new Date(), 484 }).returning({ id: roles.id }); 485 486 await ctx.db.insert(rolePermissions).values([ 487 { roleId: modRole.id, permission: "space.atbb.permission.moderatePosts" }, 488 ]); 489 490 await ctx.db.insert(users).values({ 491 did: "did:plc:test-anyperm-1", 492 handle: "anyperm1.bsky.social", 493 indexedAt: new Date(), 494 }); 495 496 await ctx.db.insert(memberships).values({ 497 did: "did:plc:test-anyperm-1", 498 rkey: "membership-anyperm-1", 499 cid: "test-cid", 500 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 501 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-1`, 502 createdAt: new Date(), 503 indexedAt: new Date(), 504 }); 505 506 const testApp = new Hono<{ Variables: Variables }>(); 507 testApp.use("*", async (c, next) => { 508 c.set("user", { 509 did: "did:plc:test-anyperm-1", 510 handle: "anyperm1.bsky.social", 511 pdsUrl: "https://pds.example.com", 512 agent: {} as any, 513 }); 514 await next(); 515 }); 516 testApp.get( 517 "/test", 518 requireAnyPermission(ctx, [ 519 "space.atbb.permission.moderatePosts", 520 "space.atbb.permission.banUsers", 521 ]), 522 (c) => c.json({ ok: true }) 523 ); 524 525 const res = await testApp.request("/test"); 526 expect(res.status).toBe(200); 527 const body = await res.json(); 528 expect(body).toEqual({ ok: true }); 529 }); 530 531 it("returns 403 when user has none of the required permissions", async () => { 532 // Create a role with only createTopics permission 533 const [memberRole] = await ctx.db.insert(roles).values({ 534 did: ctx.config.forumDid, 535 rkey: "mod-role-anyperm-2", 536 cid: "test-cid", 537 name: "Member", 538 description: "Member role", 539 priority: 30, 540 createdAt: new Date(), 541 indexedAt: new Date(), 542 }).returning({ id: roles.id }); 543 544 await ctx.db.insert(rolePermissions).values([ 545 { roleId: memberRole.id, permission: "space.atbb.permission.createTopics" }, 546 ]); 547 548 await ctx.db.insert(users).values({ 549 did: "did:plc:test-anyperm-2", 550 handle: "anyperm2.bsky.social", 551 indexedAt: new Date(), 552 }); 553 554 await ctx.db.insert(memberships).values({ 555 did: "did:plc:test-anyperm-2", 556 rkey: "membership-anyperm-2", 557 cid: "test-cid", 558 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 559 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-2`, 560 createdAt: new Date(), 561 indexedAt: new Date(), 562 }); 563 564 const testApp = new Hono<{ Variables: Variables }>(); 565 testApp.use("*", async (c, next) => { 566 c.set("user", { 567 did: "did:plc:test-anyperm-2", 568 handle: "anyperm2.bsky.social", 569 pdsUrl: "https://pds.example.com", 570 agent: {} as any, 571 }); 572 await next(); 573 }); 574 testApp.get( 575 "/test", 576 requireAnyPermission(ctx, [ 577 "space.atbb.permission.moderatePosts", 578 "space.atbb.permission.banUsers", 579 ]), 580 (c) => c.json({ ok: true }) 581 ); 582 583 const res = await testApp.request("/test"); 584 expect(res.status).toBe(403); 585 const body = await res.json(); 586 expect(body).toEqual({ error: "Insufficient permissions" }); 587 }); 588 589 it("returns 401 when user is not authenticated", async () => { 590 const testApp = new Hono<{ Variables: Variables }>(); 591 // No auth middleware — user is not set 592 testApp.get( 593 "/test", 594 requireAnyPermission(ctx, [ 595 "space.atbb.permission.moderatePosts", 596 "space.atbb.permission.banUsers", 597 ]), 598 (c) => c.json({ ok: true }) 599 ); 600 601 const res = await testApp.request("/test"); 602 expect(res.status).toBe(401); 603 }); 604 605 it("short-circuits on second permission if first fails", async () => { 606 // Create a role with banUsers but NOT moderatePosts 607 const [banRole] = await ctx.db.insert(roles).values({ 608 did: ctx.config.forumDid, 609 rkey: "mod-role-anyperm-3", 610 cid: "test-cid", 611 name: "BanRole", 612 description: "Role with banUsers only", 613 priority: 15, 614 createdAt: new Date(), 615 indexedAt: new Date(), 616 }).returning({ id: roles.id }); 617 618 await ctx.db.insert(rolePermissions).values([ 619 { roleId: banRole.id, permission: "space.atbb.permission.banUsers" }, 620 ]); 621 622 await ctx.db.insert(users).values({ 623 did: "did:plc:test-anyperm-3", 624 handle: "anyperm3.bsky.social", 625 indexedAt: new Date(), 626 }); 627 628 await ctx.db.insert(memberships).values({ 629 did: "did:plc:test-anyperm-3", 630 rkey: "membership-anyperm-3", 631 cid: "test-cid", 632 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 633 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/mod-role-anyperm-3`, 634 createdAt: new Date(), 635 indexedAt: new Date(), 636 }); 637 638 const testApp = new Hono<{ Variables: Variables }>(); 639 testApp.use("*", async (c, next) => { 640 c.set("user", { 641 did: "did:plc:test-anyperm-3", 642 handle: "anyperm3.bsky.social", 643 pdsUrl: "https://pds.example.com", 644 agent: {} as any, 645 }); 646 await next(); 647 }); 648 // First perm (moderatePosts) will fail, second (banUsers) will succeed 649 testApp.get( 650 "/test", 651 requireAnyPermission(ctx, [ 652 "space.atbb.permission.moderatePosts", 653 "space.atbb.permission.banUsers", 654 ]), 655 (c) => c.json({ ok: true }) 656 ); 657 658 const res = await testApp.request("/test"); 659 expect(res.status).toBe(200); 660 const body = await res.json(); 661 expect(body).toEqual({ ok: true }); 662 }); 663 }); 664});