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.

test(appview): failing tests for GET /api/admin/modlog (ATB-46)

Malpercio ea4b8802 29dedeb5

+270 -1
+2
apps/appview/src/lib/__tests__/test-context.ts
··· 101 101 await db.delete(posts).where(like(posts.did, "did:plc:test-%")).catch(() => {}); 102 102 await db.delete(memberships).where(like(memberships.did, "did:plc:test-%")).catch(() => {}); 103 103 await db.delete(users).where(like(users.did, "did:plc:test-%")).catch(() => {}); 104 + await db.delete(users).where(like(users.did, "did:plc:mod-%")).catch(() => {}); 105 + await db.delete(users).where(like(users.did, "did:plc:subject-%")).catch(() => {}); 104 106 await db.delete(boards).where(eq(boards.did, config.forumDid)).catch(() => {}); 105 107 await db.delete(categories).where(eq(categories.did, config.forumDid)).catch(() => {}); 106 108 await db.delete(roles).where(eq(roles.did, config.forumDid)).catch(() => {}); // cascades to role_permissions
+268 -1
apps/appview/src/routes/__tests__/admin.test.ts
··· 2 2 import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 3 3 import { Hono } from "hono"; 4 4 import type { Variables } from "../../types.js"; 5 - import { memberships, roles, rolePermissions, users, forums, categories, boards, posts } from "@atbb/db"; 5 + import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 6 6 import { eq } from "drizzle-orm"; 7 7 8 8 // Mock middleware at module level ··· 10 10 let mockGetUserRole: ReturnType<typeof vi.fn>; 11 11 let mockPutRecord: ReturnType<typeof vi.fn>; 12 12 let mockDeleteRecord: ReturnType<typeof vi.fn>; 13 + let mockRequireAnyPermissionPass = true; 13 14 14 15 // Create the mock function at module level 15 16 mockGetUserRole = vi.fn(); ··· 28 29 requirePermission: vi.fn(() => async (_c: any, next: any) => { 29 30 await next(); 30 31 }), 32 + requireAnyPermission: vi.fn(() => async (c: any, next: any) => { 33 + if (!mockRequireAnyPermissionPass) { 34 + return c.json({ error: "Insufficient permissions" }, 403); 35 + } 36 + await next(); 37 + }), 31 38 getUserRole: (...args: any[]) => mockGetUserRole(...args), 32 39 checkPermission: vi.fn().mockResolvedValue(true), 33 40 })); ··· 46 53 // Set up mock user for auth middleware 47 54 mockUser = { did: "did:plc:test-admin" }; 48 55 mockGetUserRole.mockClear(); 56 + mockRequireAnyPermissionPass = true; 49 57 50 58 // Mock putRecord 51 59 mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); ··· 2175 2183 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2176 2184 await next(); 2177 2185 }); 2186 + }); 2187 + }); 2188 + 2189 + describe("GET /api/admin/modlog", () => { 2190 + beforeEach(async () => { 2191 + await ctx.cleanDatabase(); 2192 + }); 2193 + 2194 + it("returns 401 when not authenticated", async () => { 2195 + mockUser = null; 2196 + const res = await app.request("/api/admin/modlog"); 2197 + expect(res.status).toBe(401); 2198 + }); 2199 + 2200 + it("returns 403 when user lacks all mod permissions", async () => { 2201 + mockRequireAnyPermissionPass = false; 2202 + const res = await app.request("/api/admin/modlog"); 2203 + expect(res.status).toBe(403); 2204 + }); 2205 + 2206 + it("returns empty list when no mod actions exist", async () => { 2207 + const res = await app.request("/api/admin/modlog"); 2208 + expect(res.status).toBe(200); 2209 + const data = await res.json() as any; 2210 + expect(data.actions).toEqual([]); 2211 + expect(data.total).toBe(0); 2212 + expect(data.offset).toBe(0); 2213 + expect(data.limit).toBe(50); 2214 + }); 2215 + 2216 + it("returns paginated mod actions with moderator and subject handles", async () => { 2217 + await ctx.db.insert(users).values([ 2218 + { did: "did:plc:mod-alice", handle: "alice.bsky.social", indexedAt: new Date() }, 2219 + { did: "did:plc:subject-bob", handle: "bob.bsky.social", indexedAt: new Date() }, 2220 + ]); 2221 + 2222 + await ctx.db.insert(modActions).values({ 2223 + did: ctx.config.forumDid, 2224 + rkey: "modaction-ban-1", 2225 + cid: "cid-ban-1", 2226 + action: "space.atbb.modAction.ban", 2227 + subjectDid: "did:plc:subject-bob", 2228 + subjectPostUri: null, 2229 + createdBy: "did:plc:mod-alice", 2230 + reason: "Spam", 2231 + createdAt: new Date("2026-02-26T12:01:00Z"), 2232 + indexedAt: new Date(), 2233 + }); 2234 + 2235 + const res = await app.request("/api/admin/modlog"); 2236 + expect(res.status).toBe(200); 2237 + 2238 + const data = await res.json() as any; 2239 + expect(data.total).toBe(1); 2240 + expect(data.actions).toHaveLength(1); 2241 + 2242 + const action = data.actions[0]; 2243 + expect(typeof action.id).toBe("string"); 2244 + expect(action.action).toBe("space.atbb.modAction.ban"); 2245 + expect(action.moderatorDid).toBe("did:plc:mod-alice"); 2246 + expect(action.moderatorHandle).toBe("alice.bsky.social"); 2247 + expect(action.subjectDid).toBe("did:plc:subject-bob"); 2248 + expect(action.subjectHandle).toBe("bob.bsky.social"); 2249 + expect(action.subjectPostUri).toBeNull(); 2250 + expect(action.reason).toBe("Spam"); 2251 + expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z"); 2252 + }); 2253 + 2254 + it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => { 2255 + await ctx.db.insert(users).values({ 2256 + did: "did:plc:mod-carol", 2257 + handle: "carol.bsky.social", 2258 + indexedAt: new Date(), 2259 + }); 2260 + 2261 + await ctx.db.insert(modActions).values({ 2262 + did: ctx.config.forumDid, 2263 + rkey: "modaction-hide-1", 2264 + cid: "cid-hide-1", 2265 + action: "space.atbb.modAction.hide", 2266 + subjectDid: null, 2267 + subjectPostUri: "at://did:plc:user/space.atbb.post/abc123", 2268 + createdBy: "did:plc:mod-carol", 2269 + reason: "Inappropriate", 2270 + createdAt: new Date("2026-02-26T11:30:00Z"), 2271 + indexedAt: new Date(), 2272 + }); 2273 + 2274 + const res = await app.request("/api/admin/modlog"); 2275 + expect(res.status).toBe(200); 2276 + 2277 + const data = await res.json() as any; 2278 + const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide"); 2279 + expect(action).toBeDefined(); 2280 + expect(action.subjectDid).toBeNull(); 2281 + expect(action.subjectHandle).toBeNull(); 2282 + expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123"); 2283 + }); 2284 + 2285 + it("falls back to moderatorDid when moderator has no handle indexed", async () => { 2286 + await ctx.db.insert(users).values({ 2287 + did: "did:plc:mod-nohandle", 2288 + handle: null, 2289 + indexedAt: new Date(), 2290 + }); 2291 + 2292 + await ctx.db.insert(modActions).values({ 2293 + did: ctx.config.forumDid, 2294 + rkey: "modaction-nohandle-1", 2295 + cid: "cid-nohandle-1", 2296 + action: "space.atbb.modAction.ban", 2297 + subjectDid: null, 2298 + subjectPostUri: null, 2299 + createdBy: "did:plc:mod-nohandle", 2300 + reason: "Test", 2301 + createdAt: new Date(), 2302 + indexedAt: new Date(), 2303 + }); 2304 + 2305 + const res = await app.request("/api/admin/modlog"); 2306 + expect(res.status).toBe(200); 2307 + 2308 + const data = await res.json() as any; 2309 + const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle"); 2310 + expect(action).toBeDefined(); 2311 + expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); 2312 + }); 2313 + 2314 + it("falls back to moderatorDid when moderator has no users row at all", async () => { 2315 + // Insert a mod action whose createdBy DID has NO entry in the users table 2316 + await ctx.db.insert(modActions).values({ 2317 + did: ctx.config.forumDid, 2318 + rkey: "modaction-nouser-1", 2319 + cid: "cid-nouser-1", 2320 + action: "space.atbb.modAction.ban", 2321 + subjectDid: null, 2322 + subjectPostUri: null, 2323 + createdBy: "did:plc:mod-completely-unknown", 2324 + reason: "No users row", 2325 + createdAt: new Date(), 2326 + indexedAt: new Date(), 2327 + }); 2328 + 2329 + const res = await app.request("/api/admin/modlog"); 2330 + expect(res.status).toBe(200); 2331 + 2332 + const data = await res.json() as any; 2333 + // The action must appear in the results (not silently dropped by an inner join) 2334 + const action = data.actions.find( 2335 + (a: any) => a.moderatorDid === "did:plc:mod-completely-unknown" 2336 + ); 2337 + expect(action).toBeDefined(); 2338 + expect(action.moderatorHandle).toBe("did:plc:mod-completely-unknown"); 2339 + }); 2340 + 2341 + it("returns actions in createdAt DESC order", async () => { 2342 + await ctx.db.insert(users).values({ 2343 + did: "did:plc:mod-order", 2344 + handle: "order.bsky.social", 2345 + indexedAt: new Date(), 2346 + }); 2347 + 2348 + const now = Date.now(); 2349 + await ctx.db.insert(modActions).values([ 2350 + { 2351 + did: ctx.config.forumDid, 2352 + rkey: "modaction-old", 2353 + cid: "cid-old", 2354 + action: "space.atbb.modAction.ban", 2355 + subjectDid: null, 2356 + subjectPostUri: null, 2357 + createdBy: "did:plc:mod-order", 2358 + reason: "Old action", 2359 + createdAt: new Date(now - 10000), 2360 + indexedAt: new Date(), 2361 + }, 2362 + { 2363 + did: ctx.config.forumDid, 2364 + rkey: "modaction-new", 2365 + cid: "cid-new", 2366 + action: "space.atbb.modAction.hide", 2367 + subjectDid: null, 2368 + subjectPostUri: null, 2369 + createdBy: "did:plc:mod-order", 2370 + reason: "New action", 2371 + createdAt: new Date(now), 2372 + indexedAt: new Date(), 2373 + }, 2374 + ]); 2375 + 2376 + const res = await app.request("/api/admin/modlog"); 2377 + const data = await res.json() as any; 2378 + 2379 + const orderActions = data.actions.filter((a: any) => 2380 + a.moderatorDid === "did:plc:mod-order" 2381 + ); 2382 + expect(orderActions).toHaveLength(2); 2383 + expect(orderActions[0].reason).toBe("New action"); 2384 + expect(orderActions[1].reason).toBe("Old action"); 2385 + }); 2386 + 2387 + it("respects limit and offset query params", async () => { 2388 + await ctx.db.insert(users).values({ 2389 + did: "did:plc:mod-pagination", 2390 + handle: "pagination.bsky.social", 2391 + indexedAt: new Date(), 2392 + }); 2393 + 2394 + await ctx.db.insert(modActions).values([ 2395 + { did: ctx.config.forumDid, rkey: "pag-1", cid: "c1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "A", createdAt: new Date(3000), indexedAt: new Date() }, 2396 + { did: ctx.config.forumDid, rkey: "pag-2", cid: "c2", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "B", createdAt: new Date(2000), indexedAt: new Date() }, 2397 + { did: ctx.config.forumDid, rkey: "pag-3", cid: "c3", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "C", createdAt: new Date(1000), indexedAt: new Date() }, 2398 + ]); 2399 + 2400 + const page1 = await app.request("/api/admin/modlog?limit=2&offset=0"); 2401 + const data1 = await page1.json() as any; 2402 + expect(data1.actions).toHaveLength(2); 2403 + expect(data1.limit).toBe(2); 2404 + expect(data1.offset).toBe(0); 2405 + expect(data1.total).toBe(3); 2406 + expect(data1.actions[0].reason).toBe("A"); 2407 + 2408 + const page2 = await app.request("/api/admin/modlog?limit=2&offset=2"); 2409 + const data2 = await page2.json() as any; 2410 + expect(data2.actions).toHaveLength(1); 2411 + expect(data2.total).toBe(3); 2412 + expect(data2.actions[0].reason).toBe("C"); 2413 + }); 2414 + 2415 + it("returns 400 for non-numeric limit", async () => { 2416 + const res = await app.request("/api/admin/modlog?limit=abc"); 2417 + expect(res.status).toBe(400); 2418 + const data = await res.json() as any; 2419 + expect(data.error).toMatch(/limit/i); 2420 + }); 2421 + 2422 + it("returns 400 for negative limit", async () => { 2423 + const res = await app.request("/api/admin/modlog?limit=-1"); 2424 + expect(res.status).toBe(400); 2425 + }); 2426 + 2427 + it("returns 400 for negative offset", async () => { 2428 + const res = await app.request("/api/admin/modlog?offset=-5"); 2429 + expect(res.status).toBe(400); 2430 + }); 2431 + 2432 + it("caps limit at 100", async () => { 2433 + const res = await app.request("/api/admin/modlog?limit=999"); 2434 + expect(res.status).toBe(200); 2435 + const data = await res.json() as any; 2436 + expect(data.limit).toBe(100); 2437 + }); 2438 + 2439 + it("uses default limit=50 and offset=0 when not provided", async () => { 2440 + const res = await app.request("/api/admin/modlog"); 2441 + expect(res.status).toBe(200); 2442 + const data = await res.json() as any; 2443 + expect(data.limit).toBe(50); 2444 + expect(data.offset).toBe(0); 2178 2445 }); 2179 2446 }); 2180 2447