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

fix(web): validate sort order and fix board delete 409 test (ATB-47)

- parseSortOrder now returns null for negative/non-integer values and
redirects with "Sort order must be a non-negative integer." error;
0 remains the default for empty/missing sort order fields
- Use Number() + Number.isInteger() instead of parseInt() to reject
floats like "1.5" that parseInt would silently truncate
- Add validation tests for negative sort order across all 4 create/edit
handlers (create category, edit category, create board, edit board)
- Fix board delete 409 test mock to use the real AppView error message
("Cannot delete board with posts. Remove all posts first.") and assert
the message appears in the redirect URL, matching category delete test

+86 -8
+57 -3
apps/web/src/routes/__tests__/admin.test.tsx
··· 1117 1117 expect(location).toContain("/admin/structure"); 1118 1118 expect(location).toContain("error="); 1119 1119 }); 1120 + 1121 + it("redirects with ?error= for negative sort order", async () => { 1122 + setupSession(["space.atbb.permission.manageCategories"]); 1123 + 1124 + const routes = await loadAdminRoutes(); 1125 + const res = await routes.request( 1126 + "/admin/structure/categories", 1127 + postForm({ name: "General", sortOrder: "-1" }) 1128 + ); 1129 + 1130 + expect(res.status).toBe(302); 1131 + const location = res.headers.get("location") ?? ""; 1132 + expect(location).toContain("error="); 1133 + }); 1120 1134 }); 1121 1135 1122 1136 describe("createAdminRoutes — POST /admin/structure/categories/:id/edit", () => { ··· 1215 1229 mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1216 1230 const routes = await loadAdminRoutes(); 1217 1231 const res = await routes.request("/admin/structure/categories/5/edit", postForm({ name: "Updated" })); 1232 + expect(res.status).toBe(302); 1233 + const location = res.headers.get("location") ?? ""; 1234 + expect(location).toContain("error="); 1235 + }); 1236 + 1237 + it("redirects with ?error= for negative sort order", async () => { 1238 + setupSession(["space.atbb.permission.manageCategories"]); 1239 + const routes = await loadAdminRoutes(); 1240 + const res = await routes.request( 1241 + "/admin/structure/categories/5/edit", 1242 + postForm({ name: "Updated", sortOrder: "-5" }) 1243 + ); 1218 1244 expect(res.status).toBe(302); 1219 1245 const location = res.headers.get("location") ?? ""; 1220 1246 expect(location).toContain("error="); ··· 1456 1482 const location = res.headers.get("location") ?? ""; 1457 1483 expect(location).toContain("error="); 1458 1484 }); 1485 + 1486 + it("redirects with ?error= for negative sort order", async () => { 1487 + setupSession(["space.atbb.permission.manageCategories"]); 1488 + const routes = await loadAdminRoutes(); 1489 + const res = await routes.request( 1490 + "/admin/structure/boards", 1491 + postForm({ 1492 + name: "General Chat", 1493 + categoryUri: "at://did:plc:forum/space.atbb.forum.category/abc", 1494 + sortOrder: "-2", 1495 + }) 1496 + ); 1497 + expect(res.status).toBe(302); 1498 + const location = res.headers.get("location") ?? ""; 1499 + expect(location).toContain("error="); 1500 + }); 1459 1501 }); 1460 1502 1461 1503 describe("createAdminRoutes — POST /admin/structure/boards/:id/edit", () => { ··· 1556 1598 const location = res.headers.get("location") ?? ""; 1557 1599 expect(location).toContain("error="); 1558 1600 }); 1601 + 1602 + it("redirects with ?error= for negative sort order", async () => { 1603 + setupSession(["space.atbb.permission.manageCategories"]); 1604 + const routes = await loadAdminRoutes(); 1605 + const res = await routes.request( 1606 + "/admin/structure/boards/10/edit", 1607 + postForm({ name: "Updated Board", sortOrder: "-3" }) 1608 + ); 1609 + expect(res.status).toBe(302); 1610 + const location = res.headers.get("location") ?? ""; 1611 + expect(location).toContain("error="); 1612 + }); 1559 1613 }); 1560 1614 1561 1615 describe("createAdminRoutes — POST /admin/structure/boards/:id/delete", () => { ··· 1625 1679 expect(res.headers.get("location")).toBe("/admin/structure"); 1626 1680 }); 1627 1681 1628 - it("redirects with ?error= on AppView error", async () => { 1682 + it("redirects with ?error= on AppView error (e.g. 409 has posts)", async () => { 1629 1683 setupSession(["space.atbb.permission.manageCategories"]); 1630 1684 mockFetch.mockResolvedValueOnce( 1631 - mockResponse({ error: "Board has active topics" }, false, 409) 1685 + mockResponse({ error: "Cannot delete board with posts. Remove all posts first." }, false, 409) 1632 1686 ); 1633 1687 1634 1688 const routes = await loadAdminRoutes(); ··· 1636 1690 1637 1691 expect(res.status).toBe(302); 1638 1692 const location = res.headers.get("location") ?? ""; 1639 - expect(location).toContain("error="); 1693 + expect(decodeURIComponent(location)).toContain("Cannot delete board with posts"); 1640 1694 }); 1641 1695 1642 1696 it("redirects with ?error= on network error", async () => {
+29 -5
apps/web/src/routes/admin.tsx
··· 138 138 139 139 /** 140 140 * Parses a sort order value from a form field string. 141 - * Returns 0 for invalid or missing values. 141 + * Returns 0 for empty/missing values, null for invalid values (negative or non-integer). 142 142 */ 143 - function parseSortOrder(value: unknown): number { 144 - if (typeof value !== "string") return 0; 145 - const n = parseInt(value, 10); 146 - return Number.isFinite(n) && n >= 0 ? n : 0; 143 + function parseSortOrder(value: unknown): number | null { 144 + if (typeof value !== "string" || value.trim() === "") return 0; 145 + const n = Number(value); 146 + return Number.isInteger(n) && n >= 0 ? n : null; 147 147 } 148 148 149 149 // ─── Routes ──────────────────────────────────────────────────────────────── ··· 848 848 849 849 const description = typeof body.description === "string" ? body.description.trim() || null : null; 850 850 const sortOrder = parseSortOrder(body.sortOrder); 851 + if (sortOrder === null) { 852 + return c.redirect( 853 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 854 + 302 855 + ); 856 + } 851 857 852 858 let appviewRes: Response; 853 859 try { ··· 922 928 923 929 const description = typeof body.description === "string" ? body.description.trim() || null : null; 924 930 const sortOrder = parseSortOrder(body.sortOrder); 931 + if (sortOrder === null) { 932 + return c.redirect( 933 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 934 + 302 935 + ); 936 + } 925 937 926 938 let appviewRes: Response; 927 939 try { ··· 1058 1070 1059 1071 const description = typeof body.description === "string" ? body.description.trim() || null : null; 1060 1072 const sortOrder = parseSortOrder(body.sortOrder); 1073 + if (sortOrder === null) { 1074 + return c.redirect( 1075 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 1076 + 302 1077 + ); 1078 + } 1061 1079 1062 1080 let appviewRes: Response; 1063 1081 try { ··· 1132 1150 1133 1151 const description = typeof body.description === "string" ? body.description.trim() || null : null; 1134 1152 const sortOrder = parseSortOrder(body.sortOrder); 1153 + if (sortOrder === null) { 1154 + return c.redirect( 1155 + `/admin/structure?error=${encodeURIComponent("Sort order must be a non-negative integer.")}`, 1156 + 302 1157 + ); 1158 + } 1135 1159 1136 1160 let appviewRes: Response; 1137 1161 try {