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 atb-52-css-token-extraction 471 lines 17 kB view raw
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2import { Hono } from "hono"; 3import { createAdminRoutes } from "../admin.js"; 4import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; 5import { roles, rolePermissions, memberships, users, backfillProgress, backfillErrors } from "@atbb/db"; 6import { BackfillStatus } from "../../lib/backfill-manager.js"; 7 8// Mock restoreOAuthSession so tests control auth without real OAuth 9vi.mock("../../lib/session.js", () => ({ 10 restoreOAuthSession: vi.fn(), 11})); 12 13// Mock Agent construction so auth middleware doesn't fail 14vi.mock("@atproto/api", () => ({ 15 Agent: vi.fn().mockImplementation(() => ({})), 16 AtpAgent: vi.fn().mockImplementation(() => ({ 17 com: { atproto: { repo: { listRecords: vi.fn() } } }, 18 })), 19})); 20 21import { restoreOAuthSession } from "../../lib/session.js"; 22 23const ADMIN_DID = "did:plc:test-admin-backfill"; 24const ROLE_RKEY = "admin-backfill-owner-role"; 25 26describe("Admin Backfill Routes", () => { 27 let ctx: TestContext; 28 let app: Hono; 29 let mockBackfillManager: any; 30 31 const authHeaders = { Cookie: "atbb_session=test-session" }; 32 33 beforeEach(async () => { 34 ctx = await createTestContext(); 35 36 mockBackfillManager = { 37 getIsRunning: vi.fn().mockReturnValue(false), 38 checkIfNeeded: vi.fn().mockResolvedValue(BackfillStatus.NotNeeded), 39 prepareBackfillRow: vi.fn().mockResolvedValue(42n), 40 performBackfill: vi.fn().mockResolvedValue({ 41 backfillId: 1n, 42 type: BackfillStatus.CatchUp, 43 didsProcessed: 0, 44 recordsIndexed: 0, 45 errors: 0, 46 durationMs: 100, 47 }), 48 checkForInterruptedBackfill: vi.fn().mockResolvedValue(null), 49 }; 50 51 // Inject mock backfillManager into context 52 (ctx as any).backfillManager = mockBackfillManager; 53 54 app = new Hono(); 55 app.route("/api/admin", createAdminRoutes(ctx)); 56 57 // Default: mock auth to return valid session for "test-session" 58 vi.mocked(restoreOAuthSession).mockResolvedValue({ 59 oauthSession: { 60 did: ADMIN_DID, 61 serverMetadata: { issuer: "https://pds.example.com" }, 62 } as any, 63 cookieSession: { 64 did: ADMIN_DID, 65 handle: "admin.test", 66 expiresAt: new Date(Date.now() + 3600_000), 67 createdAt: new Date(), 68 }, 69 }); 70 }); 71 72 afterEach(async () => { 73 await ctx.cleanup(); 74 vi.clearAllMocks(); 75 }); 76 77 // Helper: insert admin user with manageForum permission in DB 78 async function setupAdminUser() { 79 const [ownerRole] = await ctx.db.insert(roles).values({ 80 did: ctx.config.forumDid, 81 rkey: ROLE_RKEY, 82 cid: "test-cid", 83 name: "Owner", 84 description: "Forum owner", 85 priority: 0, 86 createdAt: new Date(), 87 indexedAt: new Date(), 88 }).returning({ id: roles.id }); 89 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 90 91 await ctx.db.insert(users).values({ 92 did: ADMIN_DID, 93 handle: "admin.test", 94 indexedAt: new Date(), 95 }); 96 97 await ctx.db.insert(memberships).values({ 98 did: ADMIN_DID, 99 rkey: "admin-backfill-membership", 100 cid: "test-cid", 101 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 102 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${ROLE_RKEY}`, 103 createdAt: new Date(), 104 indexedAt: new Date(), 105 }); 106 } 107 108 describe("POST /api/admin/backfill", () => { 109 it("returns 401 without authentication", async () => { 110 const res = await app.request("/api/admin/backfill", { method: "POST" }); 111 expect(res.status).toBe(401); 112 }); 113 114 it("returns 403 when user lacks manageForum permission", async () => { 115 // Auth but no membership/role in DB → permission check fails 116 await ctx.db.insert(users).values({ 117 did: ADMIN_DID, 118 handle: "admin.test", 119 indexedAt: new Date(), 120 }); 121 122 const res = await app.request("/api/admin/backfill", { 123 method: "POST", 124 headers: authHeaders, 125 }); 126 expect(res.status).toBe(403); 127 }); 128 129 it("returns 503 when backfillManager is not available", async () => { 130 await setupAdminUser(); 131 (ctx as any).backfillManager = null; 132 133 const res = await app.request("/api/admin/backfill", { 134 method: "POST", 135 headers: authHeaders, 136 }); 137 expect(res.status).toBe(503); 138 const data = await res.json(); 139 expect(data.error).toContain("not available"); 140 }); 141 142 it("returns 409 when backfill is already running", async () => { 143 await setupAdminUser(); 144 mockBackfillManager.getIsRunning.mockReturnValue(true); 145 146 const res = await app.request("/api/admin/backfill", { 147 method: "POST", 148 headers: authHeaders, 149 }); 150 expect(res.status).toBe(409); 151 const data = await res.json(); 152 expect(data.error).toContain("already in progress"); 153 }); 154 155 it("returns 200 with helpful message when backfill is not needed", async () => { 156 await setupAdminUser(); 157 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.NotNeeded); 158 159 const res = await app.request("/api/admin/backfill", { 160 method: "POST", 161 headers: authHeaders, 162 }); 163 expect(res.status).toBe(200); 164 const data = await res.json(); 165 expect(data.message).toContain("No backfill needed"); 166 expect(mockBackfillManager.performBackfill).not.toHaveBeenCalled(); 167 }); 168 169 it("returns 202 and triggers backfill when gap is detected", async () => { 170 await setupAdminUser(); 171 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.CatchUp); 172 173 const res = await app.request("/api/admin/backfill", { 174 method: "POST", 175 headers: authHeaders, 176 }); 177 expect(res.status).toBe(202); 178 const data = await res.json(); 179 expect(data.message).toContain("started"); 180 expect(data.type).toBe(BackfillStatus.CatchUp); 181 expect(data.status).toBe("in_progress"); 182 expect(data.id).toBe("42"); // returned by prepareBackfillRow mock 183 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.CatchUp); 184 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.CatchUp, 42n); 185 }); 186 187 it("forces catch_up backfill when ?force=catch_up is specified", async () => { 188 await setupAdminUser(); 189 // Even if checkIfNeeded says NotNeeded, force overrides 190 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.NotNeeded); 191 192 const res = await app.request("/api/admin/backfill?force=catch_up", { 193 method: "POST", 194 headers: authHeaders, 195 }); 196 expect(res.status).toBe(202); 197 const data = await res.json(); 198 expect(data.id).toBe("42"); 199 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.CatchUp); 200 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.CatchUp, 42n); 201 // checkIfNeeded should NOT be called when force is specified 202 expect(mockBackfillManager.checkIfNeeded).not.toHaveBeenCalled(); 203 }); 204 205 it("forces full_sync backfill when ?force=full_sync is specified", async () => { 206 await setupAdminUser(); 207 208 const res = await app.request("/api/admin/backfill?force=full_sync", { 209 method: "POST", 210 headers: authHeaders, 211 }); 212 expect(res.status).toBe(202); 213 const data = await res.json(); 214 expect(data.id).toBe("42"); 215 expect(mockBackfillManager.prepareBackfillRow).toHaveBeenCalledWith(BackfillStatus.FullSync); 216 expect(mockBackfillManager.performBackfill).toHaveBeenCalledWith(BackfillStatus.FullSync, 42n); 217 }); 218 219 it("falls through to gap detection when ?force is an unrecognized value", async () => { 220 await setupAdminUser(); 221 mockBackfillManager.checkIfNeeded.mockResolvedValue(BackfillStatus.CatchUp); 222 223 const res = await app.request("/api/admin/backfill?force=garbage", { 224 method: "POST", 225 headers: authHeaders, 226 }); 227 // Unrecognized force value is ignored — gap detection runs 228 expect(res.status).toBe(202); 229 expect(mockBackfillManager.checkIfNeeded).toHaveBeenCalled(); 230 }); 231 }); 232 233 describe("GET /api/admin/backfill/:id", () => { 234 it("returns 401 without authentication", async () => { 235 const res = await app.request("/api/admin/backfill/1"); 236 expect(res.status).toBe(401); 237 }); 238 239 it("returns 403 when user lacks manageForum permission", async () => { 240 // Auth works (ADMIN_DID) but no role/membership in DB 241 await ctx.db.insert(users).values({ 242 did: ADMIN_DID, 243 handle: "admin.test", 244 indexedAt: new Date(), 245 }); 246 247 const res = await app.request("/api/admin/backfill/1", { 248 headers: authHeaders, 249 }); 250 expect(res.status).toBe(403); 251 }); 252 253 it("returns 400 for non-numeric backfill ID", async () => { 254 await setupAdminUser(); 255 const res = await app.request("/api/admin/backfill/notanumber", { 256 headers: authHeaders, 257 }); 258 expect(res.status).toBe(400); 259 const data = await res.json(); 260 expect(data.error).toContain("Invalid backfill ID"); 261 }); 262 263 it("returns 400 for decimal backfill ID", async () => { 264 await setupAdminUser(); 265 const res = await app.request("/api/admin/backfill/5.9", { 266 headers: authHeaders, 267 }); 268 expect(res.status).toBe(400); 269 }); 270 271 it("returns 500 when database query fails", async () => { 272 await setupAdminUser(); 273 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 274 275 // requirePermission makes 3 DB selects (membership + role + role_permissions); let them pass, 276 // then fail on the handler's backfill_progress query (call 4). 277 const origSelect = ctx.db.select.bind(ctx.db); 278 vi.spyOn(ctx.db, "select") 279 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 280 .mockImplementationOnce(() => origSelect() as any) // permissions: role 281 .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 282 .mockReturnValueOnce({ // handler: backfill_progress 283 from: vi.fn().mockReturnValue({ 284 where: vi.fn().mockReturnValue({ 285 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 286 }), 287 }), 288 } as any); 289 290 const res = await app.request("/api/admin/backfill/123", { headers: authHeaders }); 291 expect(res.status).toBe(500); 292 293 consoleSpy.mockRestore(); 294 }); 295 296 it("returns 404 for unknown backfill ID", async () => { 297 await setupAdminUser(); 298 const res = await app.request("/api/admin/backfill/999999", { 299 headers: authHeaders, 300 }); 301 expect(res.status).toBe(404); 302 const data = await res.json(); 303 expect(data.error).toContain("not found"); 304 }); 305 306 it("returns progress data for a known backfill ID (completed)", async () => { 307 await setupAdminUser(); 308 309 const [row] = await ctx.db 310 .insert(backfillProgress) 311 .values({ 312 status: "completed", 313 backfillType: "catch_up", 314 didsTotal: 10, 315 didsProcessed: 10, 316 recordsIndexed: 50, 317 startedAt: new Date("2026-01-01T00:00:00Z"), 318 completedAt: new Date("2026-01-01T00:05:00Z"), 319 }) 320 .returning({ id: backfillProgress.id }); 321 322 const res = await app.request(`/api/admin/backfill/${row.id.toString()}`, { 323 headers: authHeaders, 324 }); 325 expect(res.status).toBe(200); 326 const data = await res.json(); 327 expect(data.id).toBe(row.id.toString()); 328 expect(data.status).toBe("completed"); 329 expect(data.type).toBe("catch_up"); 330 expect(data.didsTotal).toBe(10); 331 expect(data.didsProcessed).toBe(10); 332 expect(data.recordsIndexed).toBe(50); 333 expect(data.errorCount).toBe(0); 334 }); 335 336 it("returns in_progress status for a running backfill", async () => { 337 await setupAdminUser(); 338 339 const [row] = await ctx.db 340 .insert(backfillProgress) 341 .values({ 342 status: "in_progress", 343 backfillType: "full_sync", 344 didsTotal: 100, 345 didsProcessed: 30, 346 recordsIndexed: 75, 347 startedAt: new Date("2026-01-01T00:00:00Z"), 348 }) 349 .returning({ id: backfillProgress.id }); 350 351 const res = await app.request(`/api/admin/backfill/${row.id.toString()}`, { 352 headers: authHeaders, 353 }); 354 expect(res.status).toBe(200); 355 const data = await res.json(); 356 expect(data.status).toBe("in_progress"); 357 expect(data.didsProcessed).toBe(30); 358 expect(data.completedAt).toBeNull(); 359 }); 360 }); 361 362 describe("GET /api/admin/backfill/:id/errors", () => { 363 it("returns 401 without authentication", async () => { 364 const res = await app.request("/api/admin/backfill/1/errors"); 365 expect(res.status).toBe(401); 366 }); 367 368 it("returns 403 when user lacks manageForum permission", async () => { 369 await ctx.db.insert(users).values({ 370 did: ADMIN_DID, 371 handle: "admin.test", 372 indexedAt: new Date(), 373 }); 374 375 const res = await app.request("/api/admin/backfill/1/errors", { 376 headers: authHeaders, 377 }); 378 expect(res.status).toBe(403); 379 }); 380 381 it("returns 400 for non-numeric backfill ID", async () => { 382 await setupAdminUser(); 383 const res = await app.request("/api/admin/backfill/notanumber/errors", { 384 headers: authHeaders, 385 }); 386 expect(res.status).toBe(400); 387 }); 388 389 it("returns 500 when database query fails", async () => { 390 await setupAdminUser(); 391 const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); 392 393 const origSelect = ctx.db.select.bind(ctx.db); 394 vi.spyOn(ctx.db, "select") 395 .mockImplementationOnce(() => origSelect() as any) // permissions: membership 396 .mockImplementationOnce(() => origSelect() as any) // permissions: role 397 .mockImplementationOnce(() => origSelect() as any) // permissions: role_permissions 398 .mockReturnValueOnce({ // handler: backfill_errors query 399 from: vi.fn().mockReturnValue({ 400 where: vi.fn().mockReturnValue({ 401 orderBy: vi.fn().mockReturnValue({ 402 limit: vi.fn().mockRejectedValue(new Error("DB connection lost")), 403 }), 404 }), 405 }), 406 } as any); 407 408 const res = await app.request("/api/admin/backfill/123/errors", { headers: authHeaders }); 409 expect(res.status).toBe(500); 410 411 consoleSpy.mockRestore(); 412 }); 413 414 it("returns empty errors array when no errors exist", async () => { 415 await setupAdminUser(); 416 417 const [row] = await ctx.db 418 .insert(backfillProgress) 419 .values({ 420 status: "completed", 421 backfillType: "full_sync", 422 didsTotal: 3, 423 didsProcessed: 3, 424 recordsIndexed: 15, 425 startedAt: new Date("2026-01-01T00:00:00Z"), 426 }) 427 .returning({ id: backfillProgress.id }); 428 429 const res = await app.request(`/api/admin/backfill/${row.id.toString()}/errors`, { 430 headers: authHeaders, 431 }); 432 expect(res.status).toBe(200); 433 const data = await res.json(); 434 expect(data.errors).toHaveLength(0); 435 }); 436 437 it("returns errors for a backfill with failures", async () => { 438 await setupAdminUser(); 439 440 const [row] = await ctx.db 441 .insert(backfillProgress) 442 .values({ 443 status: "completed", 444 backfillType: "catch_up", 445 didsTotal: 5, 446 didsProcessed: 5, 447 recordsIndexed: 8, 448 startedAt: new Date("2026-01-01T00:00:00Z"), 449 }) 450 .returning({ id: backfillProgress.id }); 451 452 await ctx.db.insert(backfillErrors).values({ 453 backfillId: row.id, 454 did: "did:plc:failed-user", 455 collection: "space.atbb.post", 456 errorMessage: "PDS connection failed", 457 createdAt: new Date("2026-01-01T00:01:00Z"), 458 }); 459 460 const res = await app.request(`/api/admin/backfill/${row.id.toString()}/errors`, { 461 headers: authHeaders, 462 }); 463 expect(res.status).toBe(200); 464 const data = await res.json(); 465 expect(data.errors).toHaveLength(1); 466 expect(data.errors[0].did).toBe("did:plc:failed-user"); 467 expect(data.errors[0].collection).toBe("space.atbb.post"); 468 expect(data.errors[0].errorMessage).toBe("PDS connection failed"); 469 }); 470 }); 471});