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
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});