Public: anyone can read this; the crew can change it.
Every admin-only action refuses the everyday account's session: one test walks the route table and names any that does not #11840
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/11818-every-admin-only-action-refuses-the-ever → main 9 commits fork 1 thread · 1 unresolved
94 files
changed +888
−180
docs/ROLES.md +13
| @@ -86,6 +86,19 @@ can change the forge's settings or its releases" (`not_admin` in the API). | ||
| 86 | 86 | `src/http/admin_gate.ts` is the one gate, ahead of every handler; |
| 87 | 87 | `/releases.json` stays public. Token scopes are a later part. |
| 88 | 88 | |
| 89 | Since #11818 the gate guards every route `src/http/route_marks.ts` marks | |
| 90 | `admin` — the admin's pages under `/settings`, `/forges`, `/system`, | |
| 91 | `/maintenance` and the rest, not only `/admin` — and answers with one | |
| 92 | sentence that says what to do next: "This is for the forge's admin account, | |
| 93 | so sign in as admin to do it." An admin-only MCP tool called by name from an | |
| 94 | everyday account gets the same sentence. Every route either router registers | |
| 95 | is marked `admin` or `open` there; `test/route-marks.test.ts` fails on one | |
| 96 | marked neither, and calls each admin route and tool as an everyday account | |
| 97 | and as the admin. A head holding both kinds of route is `mixed`, so each of | |
| 98 | its routes is listed, and a path under it that no listed route matches is | |
| 99 | treated as `admin`: a forgotten mark fails closed. The 403 does tell an | |
| 100 | everyday account that an admin route or tool by that name exists. | |
| 101 | ||
| 89 | 102 | Nothing switches the refusal off: the test suite runs with it everywhere, and |
| 90 | 103 | a suite whose people make things promotes them first (`promote` in |
| 91 | 104 | `test/helpers/members.ts`). The crew's wake-up sweep, which starts a run on an |
src/core/roles.ts +3
| @@ -55,6 +55,9 @@ export const NOT_A_MEMBER = | ||
| 55 | 55 | /** The words a member (or guest) is refused admin settings and releases with. */ |
| 56 | 56 | export const NOT_AN_ADMIN = "only an admin can change the forge's settings or its releases"; |
| 57 | 57 | |
| 58 | /** The one sentence an everyday account is refused an admin-only route or tool with: why, and what to do next (#11818). */ | |
| 59 | export const SIGN_IN_AS_ADMIN = "This is for the forge's admin account, so sign in as admin to do it."; | |
| 60 | ||
| 58 | 61 | const lower = (s: string) => s.charAt(0).toLowerCase() + s.slice(1); |
| 59 | 62 | const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); |
| 60 | 63 |
src/http/admin_gate.ts +26 −17
| @@ -1,34 +1,43 @@ | ||
| 1 | 1 | /** |
| 2 | * The one gate in front of admin settings and releases: `/admin/*`, | |
| 3 | * `/api/v1/admin/*` and the `/releases` page with its rollback. A signed-in | |
| 4 | * person whose role lacks `administer` (`core/roles.ts`) is refused with a | |
| 5 | * 403 and one plain sentence, as JSON for the API and as a page for a | |
| 6 | * browser, before any handler runs. Anyone else meets the handler's own | |
| 2 | * The one gate in front of every admin-only route: those `route_marks.ts` | |
| 3 | * marks `admin` — `/admin/*`, `/api/v1/admin/*`, `/releases` with its | |
| 4 | * rollback, and the admin's pages elsewhere (`/settings/billing`, `/boards`, | |
| 5 | * …). A signed-in person whose role lacks `administer` (`core/roles.ts`) is | |
| 6 | * refused with a 403 and one plain sentence saying to sign in as admin, as | |
| 7 | * JSON for the API and as a page for a browser, before any handler runs. Anyone else meets the handler's own | |
| 7 | 8 | * rules, so an admin is unchanged. For releases the rule itself is |
| 8 | 9 | * `release/gate.ts`'s, which the terminal shares; this gate only supplies the |
| 9 | 10 | * 403 and its wording on the page. `/releases.json` stays public. |
| 10 | 11 | */ |
| 11 | 12 | |
| 12 | 13 | import type { App } from "../app.ts"; |
| 13 | import { lacks, NOT_AN_ADMIN } from "../core/roles.ts"; | |
| 14 | import { lacks, SIGN_IN_AS_ADMIN } from "../core/roles.ts"; | |
| 14 | 15 | import { forbiddenPage } from "../web/chrome.ts"; |
| 15 | 16 | import type { Auth, Ctx } from "./context.ts"; |
| 16 | 17 | import { json } from "./respond.ts"; |
| 18 | import { isAdminRoute, markOf } from "./route_marks.ts"; | |
| 17 | 19 | |
| 18 | const under = (path: string, root: string): boolean => path === root || path.startsWith(`${root}/`); | |
| 19 | ||
| 20 | /** Whether this path is one the `administer` ability guards. */ | |
| 20 | /** | |
| 21 | * Whether this path is one the `administer` ability guards: its mark in | |
| 22 | * `route_marks.ts`. `/admin/flows` is marked open there: it answers anyone but | |
| 23 | * a site admin with the not-found page (#9594), its own refusal. | |
| 24 | */ | |
| 21 | 25 | export function isAdminPath(path: string): boolean { |
| 22 | // `/admin/flows` answers anyone but a site admin with the not-found page (#9594), so it keeps its own refusal. | |
| 23 | if (path === "/admin/flows") return false; | |
| 24 | return under(path, "/admin") || under(path, "/api/v1/admin") || under(path, "/releases"); | |
| 26 | return isAdminRoute(path); | |
| 25 | 27 | } |
| 26 | 28 | |
| 27 | /** The refusal for a person who may not administer, or undefined to carry on. */ | |
| 28 | export function adminGate(app: App, request: Request, url: URL, auth: Auth): Response | undefined { | |
| 29 | if (auth.user === undefined || !isAdminPath(url.pathname)) return undefined; | |
| 29 | /** | |
| 30 | * The refusal for a person who may not administer, or undefined to carry on. | |
| 31 | * `pattern` is the route the router matched, so the mark is the one of the | |
| 32 | * handler that would run — `/packs/demo.git/info/refs` is the git route, not | |
| 33 | * a `/packs` page; without one the path's own mark decides. | |
| 34 | */ | |
| 35 | export function adminGate(app: App, request: Request, url: URL, auth: Auth, pattern?: string): Response | undefined { | |
| 36 | if (auth.user === undefined) return undefined; | |
| 37 | const mark = pattern === undefined ? undefined : markOf(pattern); | |
| 38 | if (!(mark === undefined ? isAdminPath(url.pathname) : mark === "admin")) return undefined; | |
| 30 | 39 | if (!lacks(app.db, auth.user, "administer")) return undefined; |
| 31 | if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: NOT_AN_ADMIN }, 403); | |
| 40 | if (url.pathname.startsWith("/api/")) return json({ error: "not_admin", message: SIGN_IN_AS_ADMIN }, 403); | |
| 32 | 41 | const ctx: Ctx = { app, request, url, params: {}, auth, theme: undefined }; |
| 33 | return forbiddenPage(ctx, NOT_AN_ADMIN); | |
| 42 | return forbiddenPage(ctx, SIGN_IN_AS_ADMIN); | |
| 34 | 43 | } |
src/http/mcp.ts +4 −1
| @@ -37,7 +37,7 @@ import { REPORT_RECEIVED } from "../actions/report_words.ts"; | ||
| 37 | 37 | import { BODY_MAX, REQUEST_MAX, TITLE_MAX } from "../core/reports.ts"; |
| 38 | 38 | import { everydayOf } from "../core/everyday.ts"; |
| 39 | 39 | import { claimDone, completeWrite, dropWrite, recordWrite } from "../core/conversation_writes.ts"; |
| 40 | import { type Args, alreadyDone, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts"; | |
| 40 | import { type Args, alreadyDone, notAdminTool, visible, plansOnly, str, REPO_ARG, lean, fenceAnswer, heldWrite, askedFirst, readThrough } from "./mcp/shared.ts"; | |
| 41 | 41 | import { CONVERSATION_BRIEF } from "../crew/conversation_brief.ts"; |
| 42 | 42 | import { TOOLS } from "./mcp/tools.ts"; |
| 43 | 43 | |
| @@ -191,6 +191,9 @@ function rpcOne( | ||
| 191 | 191 | typeof params["arguments"] === "object" && params["arguments"] !== null |
| 192 | 192 | ? (params["arguments"] as Args) |
| 193 | 193 | : {}; |
| 194 | // An admin's tool stays out of an everyday account's list, and a call to | |
| 195 | // it by name is told how to reach it before anything is held or asked (#11818). | |
| 196 | if (tool?.adminOnly === true && !user.isAdmin) return refuse(-32602, notAdminTool(tool.name)); | |
| 194 | 197 | // Before the scope floor, so an admin tool the forge's agent was |
| 195 | 198 | // steered to is held as a confirm too, not merely refused (#7649), and |
| 196 | 199 | // one the person asked for waits for their yes (#7650). |
src/http/route_marks.ts added +415
| @@ -0,0 +1,415 @@ | ||
| 1 | /** | |
| 2 | * Which routes are an admin's, as data (#11818). Every route either router | |
| 3 | * registers is marked `admin` or `open` here, by its head — the first path | |
| 4 | * segment, or `/api/v1/<segment>` on the API — or, under a head marked | |
| 5 | * `mixed`, by its own pattern. A head holding both kinds is `mixed`, so each | |
| 6 | * of its routes chooses; a path under a `mixed` head that no listed route | |
| 7 | * matches is treated as `admin`, so a forgotten mark fails closed. | |
| 8 | * | |
| 9 | * `admin_gate.ts` refuses a signed-in account that may not administer every | |
| 10 | * path marked `admin`, before any handler runs; `open` routes keep their | |
| 11 | * handler's own rules. `test/route-marks.test.ts` walks both routers and | |
| 12 | * fails on a route marked neither, so a new route must choose here. | |
| 13 | */ | |
| 14 | ||
| 15 | import { Router } from "./router.ts"; | |
| 16 | ||
| 17 | export type Mark = "admin" | "open"; | |
| 18 | ||
| 19 | /** Every head of a registered route: one mark for all of it, or `mixed` when each route is listed below. */ | |
| 20 | export const HEADS: Readonly<Record<string, Mark | "mixed">> = { | |
| 21 | "/": "open", | |
| 22 | "/-": "open", | |
| 23 | "/.well-known": "open", | |
| 24 | "/:owner": "open", | |
| 25 | "/__forge": "open", | |
| 26 | "/_funnel": "open", | |
| 27 | "/_measure": "open", | |
| 28 | "/_script-error": "open", | |
| 29 | "/admin": "mixed", | |
| 30 | "/agents": "open", | |
| 31 | "/api": "open", | |
| 32 | "/api/v1": "open", | |
| 33 | "/api/v1/": "open", | |
| 34 | "/api/v1/admin": "admin", | |
| 35 | "/api/v1/agents": "open", | |
| 36 | "/api/v1/app-templates": "open", | |
| 37 | "/api/v1/apps": "open", | |
| 38 | "/api/v1/asked": "open", | |
| 39 | "/api/v1/asks": "open", | |
| 40 | "/api/v1/boards": "open", | |
| 41 | "/api/v1/builds": "open", | |
| 42 | "/api/v1/changelog": "open", | |
| 43 | "/api/v1/cloud": "open", | |
| 44 | "/api/v1/codes": "open", | |
| 45 | "/api/v1/contribute": "open", | |
| 46 | "/api/v1/conversation": "open", | |
| 47 | "/api/v1/conversation-confirms": "open", | |
| 48 | "/api/v1/conversation-tokens": "open", | |
| 49 | "/api/v1/conversations": "open", | |
| 50 | "/api/v1/credit": "open", | |
| 51 | "/api/v1/crew": "open", | |
| 52 | "/api/v1/devices": "open", | |
| 53 | "/api/v1/drills": "open", | |
| 54 | "/api/v1/explore": "open", | |
| 55 | "/api/v1/family": "open", | |
| 56 | "/api/v1/features": "open", | |
| 57 | "/api/v1/feed": "open", | |
| 58 | "/api/v1/first-admin": "open", | |
| 59 | "/api/v1/flags": "open", | |
| 60 | "/api/v1/fleet": "open", | |
| 61 | "/api/v1/forge": "open", | |
| 62 | "/api/v1/forges": "open", | |
| 63 | "/api/v1/front": "open", | |
| 64 | "/api/v1/health": "open", | |
| 65 | "/api/v1/heartbeat": "open", | |
| 66 | "/api/v1/here": "open", | |
| 67 | "/api/v1/keeper": "open", | |
| 68 | "/api/v1/maintenance": "open", | |
| 69 | "/api/v1/map": "open", | |
| 70 | "/api/v1/migrate": "open", | |
| 71 | "/api/v1/openapi.json": "open", | |
| 72 | "/api/v1/orgs": "open", | |
| 73 | "/api/v1/packs": "open", | |
| 74 | "/api/v1/passport": "open", | |
| 75 | "/api/v1/plans": "open", | |
| 76 | "/api/v1/release": "open", | |
| 77 | "/api/v1/releases": "open", | |
| 78 | "/api/v1/repos": "open", | |
| 79 | "/api/v1/returns": "open", | |
| 80 | "/api/v1/search": "open", | |
| 81 | "/api/v1/secrets": "open", | |
| 82 | "/api/v1/sessions": "open", | |
| 83 | "/api/v1/share": "open", | |
| 84 | "/api/v1/start": "open", | |
| 85 | "/api/v1/telemetry": "open", | |
| 86 | "/api/v1/thoughts": "open", | |
| 87 | "/api/v1/timers": "open", | |
| 88 | "/api/v1/tokens": "open", | |
| 89 | "/api/v1/usage": "open", | |
| 90 | "/api/v1/user": "open", | |
| 91 | "/api/v1/users": "open", | |
| 92 | "/api/v1/version": "open", | |
| 93 | "/apple-touch-icon-precomposed.png": "open", | |
| 94 | "/apple-touch-icon.png": "open", | |
| 95 | "/apps": "open", | |
| 96 | "/asked": "open", | |
| 97 | "/asks": "open", | |
| 98 | "/assets": "open", | |
| 99 | "/audit": "open", | |
| 100 | "/audit.csv": "open", | |
| 101 | "/big": "open", | |
| 102 | "/board": "open", | |
| 103 | "/boards": "mixed", | |
| 104 | "/bring": "open", | |
| 105 | "/changelog": "open", | |
| 106 | "/changelog.atom": "open", | |
| 107 | "/claim": "open", | |
| 108 | "/contract": "open", | |
| 109 | "/contribute": "open", | |
| 110 | "/csp-report": "open", | |
| 111 | "/data": "open", | |
| 112 | "/deploy-to": "admin", | |
| 113 | "/displays": "open", | |
| 114 | "/docs": "open", | |
| 115 | "/events": "open", | |
| 116 | "/explore": "open", | |
| 117 | "/explore.atom": "open", | |
| 118 | "/family": "open", | |
| 119 | "/favicon.ico": "open", | |
| 120 | "/feed": "open", | |
| 121 | "/first-admin": "open", | |
| 122 | "/fleet": "open", | |
| 123 | "/flow": "mixed", | |
| 124 | "/forges": "mixed", | |
| 125 | "/front": "open", | |
| 126 | "/go": "open", | |
| 127 | "/home": "open", | |
| 128 | "/hooks": "open", | |
| 129 | "/icons": "open", | |
| 130 | "/live": "mixed", | |
| 131 | "/llms.txt": "open", | |
| 132 | "/login": "open", | |
| 133 | "/logout": "open", | |
| 134 | "/maintenance": "admin", | |
| 135 | "/manifest.webmanifest": "open", | |
| 136 | "/map": "open", | |
| 137 | "/mcp": "open", | |
| 138 | "/metrics": "open", | |
| 139 | "/migrate": "open", | |
| 140 | "/new": "open", | |
| 141 | "/notes": "open", | |
| 142 | "/now": "open", | |
| 143 | "/oauth": "open", | |
| 144 | "/og": "open", | |
| 145 | "/openapi.json": "open", | |
| 146 | "/opensearch.xml": "open", | |
| 147 | "/organizations": "open", | |
| 148 | "/origins": "open", | |
| 149 | "/packs": "mixed", | |
| 150 | "/pinned": "admin", | |
| 151 | "/pipeline": "open", | |
| 152 | "/plugins": "open", | |
| 153 | "/pricing": "open", | |
| 154 | "/privacy": "open", | |
| 155 | "/recover": "open", | |
| 156 | "/releases": "admin", | |
| 157 | "/releases.json": "open", | |
| 158 | "/repos": "open", | |
| 159 | "/resilience": "admin", | |
| 160 | "/review": "open", | |
| 161 | "/robots.txt": "open", | |
| 162 | "/runs": "open", | |
| 163 | "/search": "open", | |
| 164 | "/settings": "mixed", | |
| 165 | "/sign-in": "open", | |
| 166 | "/signin": "open", | |
| 167 | "/signup": "open", | |
| 168 | "/sitemap.xml": "open", | |
| 169 | "/sw.js": "open", | |
| 170 | "/system": "admin", | |
| 171 | "/talk": "open", | |
| 172 | "/tell": "open", | |
| 173 | "/theme": "open", | |
| 174 | "/verify": "open", | |
| 175 | "/welcome": "open", | |
| 176 | }; | |
| 177 | ||
| 178 | /** Routes marked one by one: every route under a `mixed` head, and nothing else. */ | |
| 179 | export const ROUTES: Readonly<Record<string, Mark>> = { | |
| 180 | "/boards": "open", | |
| 181 | "/boards/data": "admin", | |
| 182 | "/boards/new": "open", | |
| 183 | "/boards/holds": "open", | |
| 184 | "/boards/pin": "open", | |
| 185 | "/boards/pin-panel": "open", | |
| 186 | "/boards/unpin-panel": "open", | |
| 187 | "/boards/move-pin": "open", | |
| 188 | "/boards/:slug": "open", | |
| 189 | "/boards/:slug/live": "open", | |
| 190 | "/boards/:slug/:op": "open", | |
| 191 | "/admin/flows": "open", | |
| 192 | "/admin/agent-start": "admin", | |
| 193 | "/admin/billing-links": "admin", | |
| 194 | "/admin/data": "admin", | |
| 195 | "/admin/invites": "admin", | |
| 196 | "/admin/invites/:id/revoke": "admin", | |
| 197 | "/admin/limits": "admin", | |
| 198 | "/admin/plans": "admin", | |
| 199 | "/admin/quotas": "admin", | |
| 200 | "/admin/signup": "admin", | |
| 201 | "/flow/keeper/answer": "admin", | |
| 202 | "/flow": "open", | |
| 203 | "/flow/valve": "open", | |
| 204 | "/flow/backup/retry": "admin", | |
| 205 | "/flow/suggestion": "open", | |
| 206 | "/forges": "admin", | |
| 207 | "/forges/refused": "admin", | |
| 208 | "/forges/name/:name": "open", | |
| 209 | "/forges/:name/claim-link": "admin", | |
| 210 | "/forges/:name/revoke-link": "admin", | |
| 211 | "/forges/:name/undo-link": "admin", | |
| 212 | "/forges/:name/stop": "admin", | |
| 213 | "/forges/:name/own-code": "admin", | |
| 214 | "/forges/:name/remove": "admin", | |
| 215 | "/forges/:name/ready": "open", | |
| 216 | "/forges/:name/waiting": "open", | |
| 217 | "/forges/new": "open", | |
| 218 | "/forges/start": "open", | |
| 219 | "/live/apps": "open", | |
| 220 | "/live/on-you": "open", | |
| 221 | "/live/client-apps": "open", | |
| 222 | "/live/feed": "open", | |
| 223 | "/live/milestones": "open", | |
| 224 | "/live/moving": "open", | |
| 225 | "/live/chips": "open", | |
| 226 | "/live/home/kpis": "admin", | |
| 227 | "/live/home/waiting": "open", | |
| 228 | "/live/home/panels": "admin", | |
| 229 | "/live/home/asked": "open", | |
| 230 | "/live/home/feed": "open", | |
| 231 | "/packs": "open", | |
| 232 | "/packs/mirror": "admin", | |
| 233 | "/packs/new": "admin", | |
| 234 | "/packs/:name/edit": "admin", | |
| 235 | "/packs/:name/repos": "admin", | |
| 236 | "/packs/:name": "open", | |
| 237 | "/settings/trusted": "admin", | |
| 238 | "/settings/trusted/stop": "admin", | |
| 239 | "/settings/trusted/revert": "admin", | |
| 240 | "/settings/trusted/:id/remove": "admin", | |
| 241 | "/settings/telemetry": "admin", | |
| 242 | "/settings": "open", | |
| 243 | "/settings/notify": "open", | |
| 244 | "/settings/dotfiles": "open", | |
| 245 | "/settings/edge/link": "open", | |
| 246 | "/settings/edge/unlink": "open", | |
| 247 | "/settings/parent/link": "open", | |
| 248 | "/settings/parent/unlink": "open", | |
| 249 | "/settings/email/verify": "open", | |
| 250 | "/settings/password": "open", | |
| 251 | "/settings/everyday": "open", | |
| 252 | "/settings/everyday/options": "admin", | |
| 253 | "/settings/everyday/switch": "open", | |
| 254 | "/settings/everyday/keep": "open", | |
| 255 | "/settings/shells": "open", | |
| 256 | "/settings/shells/stop-idle": "open", | |
| 257 | "/settings/shells/:id/stop": "open", | |
| 258 | "/settings/tokens": "open", | |
| 259 | "/settings/tokens/:id/delete": "open", | |
| 260 | "/settings/sessions/:id/delete": "open", | |
| 261 | "/settings/confirms": "open", | |
| 262 | "/settings/confirms/:id": "open", | |
| 263 | "/settings/passkeys": "open", | |
| 264 | "/settings/passkeys/options": "open", | |
| 265 | "/settings/passkeys/:id/delete": "open", | |
| 266 | "/settings/subscriptions": "admin", | |
| 267 | "/settings/crew/key": "admin", | |
| 268 | "/settings/crew/budgets": "admin", | |
| 269 | "/settings/subscriptions/:id/delete": "admin", | |
| 270 | "/settings/subscriptions/:id/wake": "admin", | |
| 271 | "/settings/subscriptions/:id/headroom": "admin", | |
| 272 | "/settings/subscriptions/:id/budget": "admin", | |
| 273 | "/settings/secrets": "admin", | |
| 274 | "/settings/secrets/delete": "admin", | |
| 275 | "/settings/devices": "admin", | |
| 276 | "/settings/devices/delete": "admin", | |
| 277 | "/settings/devices/grant": "admin", | |
| 278 | "/settings/devices/revoke": "admin", | |
| 279 | "/settings/devices/cap": "admin", | |
| 280 | "/settings/cloud": "admin", | |
| 281 | "/settings/cloud/verify": "admin", | |
| 282 | "/settings/cloud/write": "admin", | |
| 283 | "/settings/cloud/audit": "admin", | |
| 284 | "/settings/cloud/rotate": "admin", | |
| 285 | "/settings/cloud/rename": "admin", | |
| 286 | "/settings/cloud/remove": "admin", | |
| 287 | "/settings/access": "admin", | |
| 288 | "/settings/resilience": "admin", | |
| 289 | "/settings/capacity": "admin", | |
| 290 | "/settings/economics": "admin", | |
| 291 | "/settings/billing": "admin", | |
| 292 | "/settings/billing/grant": "admin", | |
| 293 | "/settings/billing/links": "admin", | |
| 294 | "/settings/billing/portal": "admin", | |
| 295 | "/settings/billing/stripe/connect": "admin", | |
| 296 | "/settings/billing/stripe/secret": "admin", | |
| 297 | "/settings/billing/stripe/disconnect": "admin", | |
| 298 | "/settings/billing/orders/retry": "admin", | |
| 299 | "/settings/billing/codes": "admin", | |
| 300 | "/settings/billing/codes/revoke": "admin", | |
| 301 | "/settings/flags": "admin", | |
| 302 | "/settings/captain": "admin", | |
| 303 | "/settings/packs": "admin", | |
| 304 | "/settings/features": "admin", | |
| 305 | "/settings/features/:slug": "admin", | |
| 306 | "/settings/features/:slug/run/:tool": "admin", | |
| 307 | "/settings/features/:slug/absorb": "admin", | |
| 308 | "/settings/features/:slug/unabsorb": "admin", | |
| 309 | "/settings/features/:slug/accept": "admin", | |
| 310 | "/settings/app-templates": "admin", | |
| 311 | "/settings/usage": "admin", | |
| 312 | "/settings/spend": "admin", | |
| 313 | "/settings/traffic": "admin", | |
| 314 | "/settings/traffic/returning": "admin", | |
| 315 | "/settings/traffic/sources": "admin", | |
| 316 | "/settings/traffic/scrapes": "admin", | |
| 317 | "/settings/traffic/scrapes/export": "admin", | |
| 318 | "/settings/traffic/scrapes/capture": "admin", | |
| 319 | "/settings/traffic/scrapes/stop": "admin", | |
| 320 | "/settings/traffic/scrapes/networks": "admin", | |
| 321 | "/settings/security": "admin", | |
| 322 | "/settings/security/controls": "admin", | |
| 323 | "/settings/security/shield": "admin", | |
| 324 | "/settings/backups": "admin", | |
| 325 | "/settings/backups/live": "admin", | |
| 326 | "/settings/backups/now": "admin", | |
| 327 | "/settings/backups/drill": "admin", | |
| 328 | "/settings/backups/destination": "admin", | |
| 329 | "/settings/backups/test": "admin", | |
| 330 | "/settings/backups/clear": "admin", | |
| 331 | "/settings/backups/mirror": "admin", | |
| 332 | "/settings/backups/mirror/clear": "admin", | |
| 333 | "/settings/backups/mirror/now": "admin", | |
| 334 | "/settings/accounts": "admin", | |
| 335 | "/settings/accounts/:username/allow": "admin", | |
| 336 | "/settings/accounts/:username/decline": "admin", | |
| 337 | "/settings/people": "admin", | |
| 338 | "/settings/people/:username/role": "admin", | |
| 339 | "/settings/model": "open", | |
| 340 | "/settings/accounts/:username/model": "admin", | |
| 341 | "/settings/accounts/:username/ceiling": "admin", | |
| 342 | "/settings/accounts/:username": "admin", | |
| 343 | "/settings/accounts/:username/tokens/:id/delete": "admin", | |
| 344 | "/settings/accounts/:username/sessions/:id/delete": "admin", | |
| 345 | "/settings/accounts/:username/suspend": "admin", | |
| 346 | "/settings/accounts/:username/unsuspend": "admin", | |
| 347 | "/settings/accounts/:username/remove": "admin", | |
| 348 | "/settings/accounts/:username/retire": "admin", | |
| 349 | "/settings/accounts/:username/verify": "admin", | |
| 350 | "/settings/accounts/:username/reset": "admin", | |
| 351 | "/settings/accounts/:username/end-sessions": "admin", | |
| 352 | "/settings/accounts/:username/way-back": "admin", | |
| 353 | "/settings/crew": "admin", | |
| 354 | "/settings/crew/knobs": "admin", | |
| 355 | "/settings/teams": "admin", | |
| 356 | "/settings/teams/stream": "admin", | |
| 357 | "/settings/teams/:slug": "admin", | |
| 358 | "/settings/crew/:username": "admin", | |
| 359 | "/settings/crew/:username/delete": "admin", | |
| 360 | "/settings/crew/model-review/:id/take": "admin", | |
| 361 | "/settings/crew/model-review/:id/decline": "admin", | |
| 362 | "/settings/origins/callback": "open", | |
| 363 | "/settings/origins": "open", | |
| 364 | "/settings/origins/token": "open", | |
| 365 | "/settings/origins/oauth": "open", | |
| 366 | "/settings/origins/apps": "open", | |
| 367 | "/settings/origins/trusted-hosts": "admin", | |
| 368 | "/settings/origins/apps/:id/delete": "open", | |
| 369 | "/settings/origins/flows/:id": "open", | |
| 370 | "/settings/origins/flows/:id/poll": "open", | |
| 371 | "/settings/origins/flows/:id/stream": "open", | |
| 372 | "/settings/origins/:id/repos": "open", | |
| 373 | "/settings/origins/:id/archived": "open", | |
| 374 | "/settings/origins/:id/mirror": "open", | |
| 375 | "/settings/origins/:id/mirror/:job": "open", | |
| 376 | "/settings/origins/:id/sync": "open", | |
| 377 | "/settings/origins/:id/check": "open", | |
| 378 | "/settings/origins/:id/delete": "open", | |
| 379 | "/settings/origins/:id/token": "open", | |
| 380 | "/settings/keys": "open", | |
| 381 | "/settings/keys/:id/delete": "open", | |
| 382 | }; | |
| 383 | ||
| 384 | /** The head a pattern or a path is marked by. */ | |
| 385 | export function headOf(path: string): string { | |
| 386 | const parts = path.split("/"); | |
| 387 | return path.startsWith("/api/") ? parts.slice(0, 4).join("/") : `/${parts[1] ?? ""}`; | |
| 388 | } | |
| 389 | ||
| 390 | /** A pattern's mark, or undefined when it has none: a route that has not chosen. */ | |
| 391 | export function markOf(pattern: string): Mark | undefined { | |
| 392 | const own = ROUTES[pattern]; | |
| 393 | if (own !== undefined) return own; | |
| 394 | const head = HEADS[headOf(pattern)]; | |
| 395 | return head === "mixed" ? undefined : head; | |
| 396 | } | |
| 397 | ||
| 398 | let listed: Router<Mark> | undefined; | |
| 399 | /** ROUTES as a matcher, literal segments before parameters so `/packs/new` is not read as `/packs/:name`. */ | |
| 400 | function listedRoutes(): Router<Mark> { | |
| 401 | if (listed !== undefined) return listed; | |
| 402 | const params = (p: string) => p.split("/").filter((s) => s.startsWith(":") || s.startsWith("*")).length; | |
| 403 | const matcher = new Router<Mark>(); | |
| 404 | for (const pattern of Object.keys(ROUTES).sort((a, b) => params(a) - params(b))) | |
| 405 | matcher.add("*", pattern, ROUTES[pattern] as Mark); | |
| 406 | return (listed = matcher); | |
| 407 | } | |
| 408 | ||
| 409 | /** Whether a request path falls on a route marked `admin`; unlisted under a `mixed` head counts as `admin`. */ | |
| 410 | export function isAdminRoute(path: string): boolean { | |
| 411 | const own = listedRoutes().match("*", path); | |
| 412 | if (own !== undefined) return own.handler === "admin"; | |
| 413 | const head = HEADS[headOf(path)]; | |
| 414 | return head === "admin" || head === "mixed"; | |
| 415 | } |
src/http/server.ts +1 −1
| @@ -3020,7 +3020,7 @@ async function answerGet( | ||
| 3020 | 3020 | if (viaRefusal !== undefined) return viaRefusal; |
| 3021 | 3021 | |
| 3022 | 3022 | // Admin settings and releases are an admin's; a member is told so. |
| 3023 | const adminRefusal = adminGate(app, request, url, auth); | |
| 3023 | const adminRefusal = adminGate(app, request, url, auth, matched.pattern); | |
| 3024 | 3024 | if (adminRefusal !== undefined) return adminRefusal; |
| 3025 | 3025 | |
| 3026 | 3026 | // A transferred repository's old path answers 308 to its new one (#569). |
test/access-language.test.ts +2 −2
| @@ -70,7 +70,7 @@ test("one logged request: the filter and a table row, escaped, no card", async ( | ||
| 70 | 70 | expect(page).toContain("<b>probe"); |
| 71 | 71 | }); |
| 72 | 72 | |
| 73 | test("a non-admin still gets 404", async () => { | |
| 73 | test("a non-admin is refused with 403", async () => { | |
| 74 | 74 | const res = await fetch(servers.url + "/settings/access", { headers: { cookie: cookies.bob! } }); |
| 75 | expect(res.status).toBe(404); | |
| 75 | expect(res.status).toBe(403); | |
| 76 | 76 | }); |
test/account-access.test.ts +4 −4
| @@ -86,20 +86,20 @@ describe("an admin resets a person's access", () => { | ||
| 86 | 86 | await rm(dataDir, { recursive: true, force: true }); |
| 87 | 87 | }); |
| 88 | 88 | |
| 89 | test("a profile offers Manage account to an admin only, and the page answers 404 to anyone else", async () => { | |
| 89 | test("a profile offers Manage account to an admin only, and the page refuses anyone else", async () => { | |
| 90 | 90 | const asAdmin = await (await get("/bob", owner)).text(); |
| 91 | 91 | expect(asAdmin).toContain("/settings/accounts/bob"); |
| 92 | 92 | expect(asAdmin).toContain("Manage account"); |
| 93 | 93 | expect(await (await get("/bob", bobCookie)).text()).not.toContain("Manage account"); |
| 94 | 94 | expect(await (await get("/bob")).text()).not.toContain("Manage account"); |
| 95 | expect((await get("/settings/accounts/bob", bobCookie)).status).toBe(404); | |
| 95 | expect((await get("/settings/accounts/bob", bobCookie)).status).toBe(403); | |
| 96 | 96 | const page = await get("/settings/accounts/bob", owner); |
| 97 | 97 | expect(page.status).toBe(200); |
| 98 | 98 | const text = await page.text(); |
| 99 | 99 | for (const word of ["Role", "[email protected]", "Verified", "a password is set", "Owns", "Signed in now", "Reset access", "End all sessions", "Model budget"]) |
| 100 | 100 | expect(text).toContain(word); |
| 101 | 101 | expect(text).not.toContain("bob-old-passphrase"); |
| 102 | expect((await post("/settings/accounts/bob/reset", bobCookie)).status).toBe(404); | |
| 102 | expect((await post("/settings/accounts/bob/reset", bobCookie)).status).toBe(403); | |
| 103 | 103 | }); |
| 104 | 104 | |
| 105 | 105 | test("the accounts list searches by name", async () => { |
| @@ -217,7 +217,7 @@ describe("an admin resets a person's access", () => { | ||
| 217 | 217 | expect(ended.sessionsEnded).toBe(0); |
| 218 | 218 | }); |
| 219 | 219 | |
| 220 | test("only the forge's owner manages another admin; a member is told 404", async () => { | |
| 220 | test("only the forge's owner manages another admin; a member is refused with 403", async () => { | |
| 221 | 221 | const olive = createToken(app.db, getUserByName(app.db, "olive")!.id, "t", "admin").token; |
| 222 | 222 | const refused = await api("/api/v1/admin/accounts/olive/reset", adminTwo); |
| 223 | 223 | expect(refused.status).toBe(422); |
test/accounts-language.test.ts +2 −2
| @@ -61,7 +61,7 @@ test("the accounts list keeps its confirm links", async () => { | ||
| 61 | 61 | expect(m).toContain("/-/confirm?to=%2Fsettings%2Faccounts%2Fbobby%2Fsuspend"); |
| 62 | 62 | }); |
| 63 | 63 | |
| 64 | test("a non-admin gets the one 404", async () => { | |
| 64 | test("a non-admin gets the one 403", async () => { | |
| 65 | 65 | const r = await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: plain } }); |
| 66 | expect(r.status).toBe(404); | |
| 66 | expect(r.status).toBe(403); | |
| 67 | 67 | }); |
test/accounts-retire.test.ts +2 −2
| @@ -125,8 +125,8 @@ describe("accounts: the confirm page for removing an organization too", () => { | ||
| 125 | 125 | |
| 126 | 126 | test("a non-admin and a cross-origin post are refused, changing nothing", async () => { |
| 127 | 127 | await seed("rc"); |
| 128 | expect((await fetch(`${base}/settings/accounts/rc/retire`, { headers: other })).status).toBe(404); | |
| 129 | expect((await post("rc", other)).status).toBe(404); | |
| 128 | expect((await fetch(`${base}/settings/accounts/rc/retire`, { headers: other })).status).toBe(403); | |
| 129 | expect((await post("rc", other)).status).toBe(403); | |
| 130 | 130 | expect((await post("rc", { ...admin, origin: "https://evil.example" })).status).toBe(403); |
| 131 | 131 | expect(getUserByName(app.db, "rc")?.removedAt).toBeNull(); |
| 132 | 132 | expect(existsSync(repoDir(app.reposRoot, "rcco", "ledger"))).toBe(true); |
test/accounts-rows.test.ts +2 −2
| @@ -107,11 +107,11 @@ test("the suspend confirm, the removal refusal, the retire page and the removed | ||
| 107 | 107 | expect(main).toContain("<h2>Removed</h2>"); |
| 108 | 108 | }); |
| 109 | 109 | |
| 110 | test("a non-admin still gets the 404, and the page costs a handful of statements", async () => { | |
| 110 | test("a non-admin is refused with 403, and the page costs a handful of statements", async () => { | |
| 111 | 111 | const mallory = await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]", password: "correct-horse-battery" }); |
| 112 | 112 | if (!mallory.ok) throw new Error(mallory.error); |
| 113 | 113 | const theirs = `forge_session=${createSession(app.db, getUserByName(app.db, "mallory")!.id).token}`; |
| 114 | expect((await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: theirs } })).status).toBe(404); | |
| 114 | expect((await fetch(`${servers.url}/settings/accounts`, { headers: { cookie: theirs } })).status).toBe(403); | |
| 115 | 115 | await fetch(`${servers.url}/settings/accounts`, { headers: { cookie } }); |
| 116 | 116 | expectWork(await requestStatements(app.db, `${servers.url}/settings/accounts`, { headers: { cookie } }), { max: 25 }); |
| 117 | 117 | }); |
test/accounts.test.ts +1 −1
| @@ -175,7 +175,7 @@ describe("accounts: the owner sees, suspends and removes", () => { | ||
| 175 | 175 | const page = await fetch(`${base}/settings/accounts`, { |
| 176 | 176 | headers: { authorization: `Bearer ${token}` }, |
| 177 | 177 | }); |
| 178 | expect(page.status).toBe(404); | |
| 178 | expect(page.status).toBe(403); | |
| 179 | 179 | }); |
| 180 | 180 | |
| 181 | 181 | test("the owner's list shows each account, how it came, and what it holds", async () => { |
test/admin-gate.test.ts +3 −3
| @@ -9,7 +9,7 @@ import { mkdtemp, rm } from "node:fs/promises"; | ||
| 9 | 9 | import { tmpdir } from "node:os"; |
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 12 | import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 13 | 13 | import { createSession } from "../src/core/sessions.ts"; |
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -118,7 +118,7 @@ describe("the admin gate", () => { | ||
| 118 | 118 | const res = await api("GET", "/api/v1/admin/settings", u); |
| 119 | 119 | expect(res.status).toBe(403); |
| 120 | 120 | expect(res.headers.get("content-type")).toContain("application/json"); |
| 121 | expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN }); | |
| 121 | expect(await res.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN }); | |
| 122 | 122 | } |
| 123 | 123 | }); |
| 124 | 124 | |
| @@ -130,7 +130,7 @@ describe("the admin gate", () => { | ||
| 130 | 130 | expect(res.headers.get("content-type")).toContain("text/html"); |
| 131 | 131 | const body = await res.text(); |
| 132 | 132 | expect(body).toContain("<h1>You can't do that here</h1>"); |
| 133 | expect(body).toContain(NOT_AN_ADMIN.replace("'", "'")); | |
| 133 | expect(body).toContain(SIGN_IN_AS_ADMIN.replace("'", "'")); | |
| 134 | 134 | } |
| 135 | 135 | } |
| 136 | 136 | }); |
test/admin_refused.test.ts +3 −3
| @@ -11,7 +11,7 @@ import { join } from "node:path"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { createRepo } from "../src/actions/repos.ts"; |
| 13 | 13 | import { mayRead, mayRollBack } from "../src/release/gate.ts"; |
| 14 | import { NOT_AN_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 14 | import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 15 | 15 | import { createSession } from "../src/core/sessions.ts"; |
| 16 | 16 | import { createToken } from "../src/core/tokens.ts"; |
| 17 | 17 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -86,7 +86,7 @@ describe("a member is refused admin settings and releases", () => { | ||
| 86 | 86 | for (const [method, path, body] of apiActs) { |
| 87 | 87 | const refused = await call(method, path, carol, body); |
| 88 | 88 | expect(refused.status, `${method} ${path}`).toBe(403); |
| 89 | expect(await refused.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN }); | |
| 89 | expect(await refused.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN }); | |
| 90 | 90 | } |
| 91 | 91 | for (const path of ["/api/v1/admin/capacity", "/api/v1/admin/limits", "/api/v1/admin/people"]) |
| 92 | 92 | expect((await call("GET", path, alice)).status, path).toBe(200); |
| @@ -104,7 +104,7 @@ describe("a member is refused admin settings and releases", () => { | ||
| 104 | 104 | for (const [method, path] of pages) { |
| 105 | 105 | const refused = await visit(method, path, carol); |
| 106 | 106 | expect(refused.status, `${method} ${path}`).toBe(403); |
| 107 | expect(await refused.text()).toContain(NOT_AN_ADMIN.replace("'", "'")); | |
| 107 | expect(await refused.text()).toContain(SIGN_IN_AS_ADMIN.replace("'", "'")); | |
| 108 | 108 | } |
| 109 | 109 | expect((await visit("GET", "/admin/limits", alice)).status).toBe(200); |
| 110 | 110 | expect((await visit("GET", "/releases", alice)).status).toBe(200); |
test/agent-language.test.ts +2 −2
| @@ -69,6 +69,6 @@ test("an agent's page: one h1, plain sections, both forms, no card", async () => | ||
| 69 | 69 | expect(page).toContain("to=%2Fsettings%2Fcrew%2Fscoutly%2Fdelete"); |
| 70 | 70 | }); |
| 71 | 71 | |
| 72 | test("a non-admin still gets 404", async () => { | |
| 73 | expect((await fetchPage(member)).status).toBe(404); | |
| 72 | test("a non-admin is refused with 403", async () => { | |
| 73 | expect((await fetchPage(member)).status).toBe(403); | |
| 74 | 74 | }); |
test/alarm-backup.test.ts +1 −1
| @@ -100,7 +100,7 @@ describe("/flow backup card", () => { | ||
| 100 | 100 | const res = await retry(await login("alice")); |
| 101 | 101 | expect(res.status).toBe(303); |
| 102 | 102 | expect(res.headers.get("location")).toBe("/flow"); |
| 103 | expect((await retry(await login("bob"))).status).toBe(404); | |
| 103 | expect((await retry(await login("bob"))).status).toBe(403); | |
| 104 | 104 | expect((await retry(await login("alice"), "https://evil.example")).status).toBeGreaterThanOrEqual(400); |
| 105 | 105 | }); |
| 106 | 106 | }); |
test/backups-live.test.ts +1 −1
| @@ -159,7 +159,7 @@ test("a failed snapshot ends in backup.finished {ok:false}, and the live part sh | ||
| 159 | 159 | test("the live part and the stream are an admin's alone", async () => { |
| 160 | 160 | const bob = await login("bob", "another-good-passphrase"); |
| 161 | 161 | const part = await fetch(`${servers.url}/settings/backups/live`, { headers: { cookie: bob } }); |
| 162 | expect(part.status).toBe(404); | |
| 162 | expect(part.status).toBe(403); | |
| 163 | 163 | expect((await fetch(`${servers.url}/settings/backups/live`)).status).toBeGreaterThanOrEqual(300); |
| 164 | 164 | |
| 165 | 165 | const frames = async (cookie: string, mustArrive: boolean): Promise<string> => { |
test/billing-links.test.ts +1 −1
| @@ -198,7 +198,7 @@ test("Settings → Billing: set three links, the home shows them; clear, the hom | ||
| 198 | 198 | const page = await (await fetch(`${servers.url}/settings/billing`, { headers: { cookie: adminCookie } })).text(); |
| 199 | 199 | expect(page).toContain("Payment links"); |
| 200 | 200 | for (const z of ["small", "medium", "large"]) expect(page).toContain(`name="${z}"`); |
| 201 | expect((await links(memberCookie, LINKS)).status).toBe(404); | |
| 201 | expect((await links(memberCookie, LINKS)).status).toBe(403); | |
| 202 | 202 | const bad = await links(adminCookie, { ...LINKS, large: "http://nope.test" }); |
| 203 | 203 | expect(bad.status).toBe(422); |
| 204 | 204 | const badPage = await bad.text(); |
test/billing.test.ts +3 −3
| @@ -139,7 +139,7 @@ describe("billing seam", () => { | ||
| 139 | 139 | expect(text).toContain("Choose the plan each forge is on."); |
| 140 | 140 | expect(text).toContain("Paste a Stripe secret or restricted key"); |
| 141 | 141 | expect(text).toContain('data-forge="kid"'); |
| 142 | expect((await send("GET", "/settings/billing", bob)).status).toBe(404); | |
| 142 | expect((await send("GET", "/settings/billing", bob)).status).toBe(403); | |
| 143 | 143 | }); |
| 144 | 144 | |
| 145 | 145 | test("the Billing page says the last Stripe event and the refused signatures, or that none came", async () => { |
| @@ -157,9 +157,9 @@ describe("billing seam", () => { | ||
| 157 | 157 | expect(after).toContain("<dt>Refused today</dt>"); |
| 158 | 158 | }); |
| 159 | 159 | |
| 160 | test("a non-admin gets 404 and no event data on the Billing page", async () => { | |
| 160 | test("a non-admin gets 403 and no event data on the Billing page", async () => { | |
| 161 | 161 | const res = await send("GET", "/settings/billing", bob); |
| 162 | expect(res.status).toBe(404); | |
| 162 | expect(res.status).toBe(403); | |
| 163 | 163 | const text = await res.text(); |
| 164 | 164 | expect(text).not.toContain("invoice.paid"); |
| 165 | 165 | expect(text).not.toContain("Refused today"); |
test/boards-guide.test.ts +2 −2
| @@ -176,13 +176,13 @@ describe("/boards/data", () => { | ||
| 176 | 176 | expect(body.split("Average CPU cores per day, where target is forge.").length - 1).toBe(1); |
| 177 | 177 | expect(body).toMatch(/<div class="card-foot">\s*Average CPU cores per day/); |
| 178 | 178 | }); |
| 179 | test("a member who is not a site admin gets 404 for a sentence, and no redirect", async () => { | |
| 179 | test("a member who is not a site admin gets 403 for a sentence, and no redirect", async () => { | |
| 180 | 180 | const r = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); |
| 181 | 181 | if (!r.ok) throw new Error(r.error); |
| 182 | 182 | const member = createToken(app.db, r.value.id, "t", "admin").token; |
| 183 | 183 | for (const q of ["ask=memory+over+the+last+3+days", "ask=zzz"]) { |
| 184 | 184 | const res = await fetch(`${servers.url}/boards/data?${q}`, { redirect: "manual", headers: { authorization: `Bearer ${member}` } }); |
| 185 | expect(res.status).toBe(404); | |
| 185 | expect(res.status).toBe(403); | |
| 186 | 186 | expect(res.headers.get("location")).toBeNull(); |
| 187 | 187 | } |
| 188 | 188 | const anon = await fetch(`${servers.url}/boards/data?ask=memory`, { redirect: "manual" }); |
test/boards-model-fill.test.ts +3 −3
| @@ -163,10 +163,10 @@ describe("/boards/data?ask=", () => { | ||
| 163 | 163 | expect((await post("zzz qqq", { origin: "https://evil.example" })).status).toBe(403); |
| 164 | 164 | expect(calls).toEqual([]); |
| 165 | 165 | }); |
| 166 | test("a member who is not an admin gets 404 and the model is not asked", async () => { | |
| 166 | test("a member who is not an admin gets 403 and the model is not asked", async () => { | |
| 167 | 167 | useModel(stub(JSON.stringify(GOOD))); |
| 168 | expect((await get("/boards/data?ask=zzz+qqq", {}, member)).status).toBe(404); | |
| 169 | expect((await fetch(servers.url + "/boards/data", { method: "POST", body: new URLSearchParams({ ask: "zzz" }), headers: { authorization: `Bearer ${member}`, origin: servers.url } })).status).toBe(404); | |
| 168 | expect((await get("/boards/data?ask=zzz+qqq", {}, member)).status).toBe(403); | |
| 169 | expect((await fetch(servers.url + "/boards/data", { method: "POST", body: new URLSearchParams({ ask: "zzz" }), headers: { authorization: `Bearer ${member}`, origin: servers.url } })).status).toBe(403); | |
| 170 | 170 | expect(calls).toEqual([]); |
| 171 | 171 | }); |
| 172 | 172 | }); |
test/boards-page.test.ts +2 −2
| @@ -160,8 +160,8 @@ describe("/boards/data", () => { | ||
| 160 | 160 | expect(body).not.toContain("<script>x</script>"); |
| 161 | 161 | }); |
| 162 | 162 | |
| 163 | test("a non-admin and a stranger get 404; the admin's menu links Boards", async () => { | |
| 164 | expect((await get("/boards/data", "bob")).status).toBe(404); | |
| 163 | test("a non-admin gets 403 and a stranger 404; the admin's menu links Boards", async () => { | |
| 164 | expect((await get("/boards/data", "bob")).status).toBe(403); | |
| 165 | 165 | expect((await get("/boards/data", null)).status).toBe(404); |
| 166 | 166 | expect(await (await get("/", "admin")).text()).toContain('href="/boards"'); |
| 167 | 167 | expect(await (await get("/", "bob")).text()).not.toContain('href="/boards"'); |
test/capacity-selfswap.test.ts +2 −2
| @@ -124,7 +124,7 @@ describe("self-swap on /capacity and MCP capacity", () => { | ||
| 124 | 124 | } |
| 125 | 125 | }); |
| 126 | 126 | |
| 127 | test("a member gets no selfSwap from the tool and a 404 from the page", async () => { | |
| 127 | test("a member gets no selfSwap from the tool and a 403 from the page", async () => { | |
| 128 | 128 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); |
| 129 | 129 | if (!bob.ok) throw new Error(bob.error); |
| 130 | 130 | const token = createToken(app.db, bob.value.id, "t", "admin").token; |
| @@ -132,7 +132,7 @@ describe("self-swap on /capacity and MCP capacity", () => { | ||
| 132 | 132 | expect(result.isError).toBe(true); |
| 133 | 133 | expect(result.content[0]!.text).not.toContain("selfSwap"); |
| 134 | 134 | const res = await fetch(`${servers.url}/settings/capacity`, { headers: { authorization: `Bearer ${token}` } }); |
| 135 | expect(res.status).toBe(404); | |
| 135 | expect(res.status).toBe(403); | |
| 136 | 136 | expect(await res.text()).not.toContain("self-swap"); |
| 137 | 137 | }); |
| 138 | 138 | }); |
test/capacity.test.ts +2 −2
| @@ -199,7 +199,7 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 199 | 199 | headers: { cookie: bobCookie }, |
| 200 | 200 | }) |
| 201 | 201 | ).status, |
| 202 | ).toBe(404); | |
| 202 | ).toBe(403); | |
| 203 | 203 | const bobPost = await fetch(`${servers.url}/settings/capacity`, { |
| 204 | 204 | method: "POST", |
| 205 | 205 | redirect: "manual", |
| @@ -210,7 +210,7 @@ describe("capacity: only a forge admin reads or changes it", () => { | ||
| 210 | 210 | }, |
| 211 | 211 | body: new URLSearchParams({ key: "checks.concurrency", value: "9" }), |
| 212 | 212 | }); |
| 213 | expect(bobPost.status).toBe(404); | |
| 213 | expect(bobPost.status).toBe(403); | |
| 214 | 214 | |
| 215 | 215 | for (const token of [bobAdmin, agentRw]) { |
| 216 | 216 | // A person who is not an admin is refused; an agent is told it is not there. |
test/claim-revoke.test.ts +2 −1
| @@ -179,7 +179,8 @@ describe("revoking a forge link", () => { | ||
| 179 | 179 | |
| 180 | 180 | test("the routes: a member and a guest get nothing, and another origin is refused", async () => { |
| 181 | 181 | for (const cookie of [bobCookie, ""]) { |
| 182 | for (const act of ["revoke", "undo"] as const) expect((await post("one", act, cookie)).status).toBe(404); | |
| 182 | // A member is refused at the admin gate; a stranger finds nothing. | |
| 183 | for (const act of ["revoke", "undo"] as const) expect((await post("one", act, cookie)).status).toBe(cookie === "" ? 404 : 403); | |
| 183 | 184 | } |
| 184 | 185 | const crossed = await post("one", "revoke", aliceCookie, "https://evil.test"); |
| 185 | 186 | expect(crossed.headers.get("location") ?? "").not.toContain("link=revoked"); |
test/client-list.test.ts +2 −2
| @@ -151,7 +151,7 @@ describe("the client list", () => { | ||
| 151 | 151 | }); |
| 152 | 152 | |
| 153 | 153 | test("someone who is not an admin of the parent gets no list: page, API or MCP", async () => { |
| 154 | expect((await list(reader)).status).toBe(404); | |
| 154 | expect((await list(reader)).status).toBe(403); | |
| 155 | 155 | const tool = (await (await mcp(bobRw)).json()) as any; |
| 156 | 156 | expect(tool.result?.isError ?? tool.error !== undefined).toBe(true); |
| 157 | 157 | expect(JSON.stringify(tool)).not.toContain("maria"); |
| @@ -251,7 +251,7 @@ describe("the client list", () => { | ||
| 251 | 251 | headers: { cookie, origin: "https://forge.test" }, |
| 252 | 252 | redirect: "manual", |
| 253 | 253 | }); |
| 254 | expect((await fresh(reader)).status).toBe(404); | |
| 254 | expect((await fresh(reader)).status).toBe(403); | |
| 255 | 255 | const res = await fresh(admin); |
| 256 | 256 | expect(res.status).toBe(201); |
| 257 | 257 | expect(res.headers.get("cache-control")).toContain("no-store"); |
test/cloud-gate.test.ts +1 −1
| @@ -119,7 +119,7 @@ describe("cloud gate", () => { | ||
| 119 | 119 | expect(main).toContain("Used for nothing now"); |
| 120 | 120 | const bobCookie = await login("bob"); |
| 121 | 121 | for (const path of ["/settings/cloud", "/settings/cloud/audit"]) |
| 122 | expect((await fetch(`${servers.url}${path}`, { headers: { cookie: bobCookie } })).status).toBe(404); | |
| 122 | expect((await fetch(`${servers.url}${path}`, { headers: { cookie: bobCookie } })).status).toBe(403); | |
| 123 | 123 | }); |
| 124 | 124 | |
| 125 | 125 | test("a gated verify audits each request; bytes out counts the body", async () => { |
test/cloud-page-shape.test.ts +2 −2
| @@ -57,7 +57,7 @@ test("today's vendor calls are a headed block, no card; with none one sentence s | ||
| 57 | 57 | expect(main).toContain("No vendor call today. Rows are kept 90 days."); |
| 58 | 58 | }); |
| 59 | 59 | |
| 60 | test("a signed-in non-admin gets the forge's one 404 for the vendor calls", async () => { | |
| 60 | test("a signed-in non-admin gets the gate's 403 for the vendor calls", async () => { | |
| 61 | 61 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); |
| 62 | 62 | if (!made.ok) throw new Error(made.error); |
| 63 | 63 | const r = await fetch(`${servers.url}/signin`, { |
| @@ -68,6 +68,6 @@ test("a signed-in non-admin gets the forge's one 404 for the vendor calls", asyn | ||
| 68 | 68 | }); |
| 69 | 69 | const bob = (r.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 70 | 70 | const res = await fetch(`${servers.url}/settings/cloud/audit`, { headers: { cookie: bob } }); |
| 71 | expect(res.status).toBe(404); | |
| 71 | expect(res.status).toBe(403); | |
| 72 | 72 | expect(await res.text()).not.toContain("vendor call"); |
| 73 | 73 | }); |
test/cloud.test.ts +1 −1
| @@ -165,7 +165,7 @@ describe("cloud accounts", () => { | ||
| 165 | 165 | expect(page).toContain("verify"); |
| 166 | 166 | expect(page).toContain("read-only"); |
| 167 | 167 | expect(page).not.toContain(TOKEN); |
| 168 | expect((await get(await login("bob"))).status).toBe(404); | |
| 168 | expect((await get(await login("bob"))).status).toBe(403); | |
| 169 | 169 | const cross = await fetch(`${servers.url}/settings/cloud/write`, { |
| 170 | 170 | method: "POST", |
| 171 | 171 | redirect: "manual", |
test/confirms-language.test.ts +2 −2
| @@ -68,6 +68,6 @@ test("a waiting ask is a row in a .list with its same-origin Yes form", async () | ||
| 68 | 68 | expect(main(await (await get("/settings/confirms", plain)).text())).not.toContain(asked.needs_confirm); |
| 69 | 69 | }); |
| 70 | 70 | |
| 71 | test("a non-admin gets the one 404 on the admin pages", async () => { | |
| 72 | expect((await get("/settings/accounts", plain)).status).toBe(404); | |
| 71 | test("a non-admin gets the one 403 on the admin pages", async () => { | |
| 72 | expect((await get("/settings/accounts", plain)).status).toBe(403); | |
| 73 | 73 | }); |
test/crew-handover.test.ts +1 −1
| @@ -113,7 +113,7 @@ describe("what the crew handed on", () => { | ||
| 113 | 113 | |
| 114 | 114 | test("a non-admin gets neither", async () => { |
| 115 | 115 | const { status, text } = await crewPage("bob"); |
| 116 | expect(status).toBe(404); | |
| 116 | expect(status).toBe(403); | |
| 117 | 117 | expect(text).not.toContain("Handed to the planner"); |
| 118 | 118 | expect(text).not.toContain("Restore drill"); |
| 119 | 119 | expect(await terminal("bob")).not.toContain("Handed to the planner: "); |
test/crew-key.test.ts +2 −2
| @@ -61,8 +61,8 @@ describe("adding the crew's key", () => { | ||
| 61 | 61 | expect(listSubscriptions(app.db)).toEqual([]); |
| 62 | 62 | }); |
| 63 | 63 | |
| 64 | test("a non-admin gets the not-found page", async () => { | |
| 65 | expect((await post(plain, GOOD)).status).toBe(404); | |
| 64 | test("a non-admin gets the 403 page", async () => { | |
| 65 | expect((await post(plain, GOOD)).status).toBe(403); | |
| 66 | 66 | expect(listSubscriptions(app.db)).toEqual([]); |
| 67 | 67 | }); |
| 68 | 68 |
test/crew-language.test.ts +2 −2
| @@ -80,7 +80,7 @@ test("with an agent: its link is on the page, still no card", async () => { | ||
| 80 | 80 | expect(main).toContain('href="/settings/crew/bot"'); |
| 81 | 81 | }); |
| 82 | 82 | |
| 83 | test("a non-admin still gets 404", async () => { | |
| 83 | test("a non-admin is refused with 403", async () => { | |
| 84 | 84 | const res = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: other }, redirect: "manual" }); |
| 85 | expect(res.status).toBe(404); | |
| 85 | expect(res.status).toBe(403); | |
| 86 | 86 | }); |
test/crew-teams.test.ts +3 −3
| @@ -79,10 +79,10 @@ describe("crew teams", () => { | ||
| 79 | 79 | expect(text).not.toContain("Owns <b>looks"); |
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | test("a non-admin gets the 404 on read and write, and a cross-origin post is refused", async () => { | |
| 82 | test("a non-admin gets 403 on read and write, and a cross-origin post is refused", async () => { | |
| 83 | 83 | const page = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: cookie(bob) } }); |
| 84 | expect(page.status).toBe(404); | |
| 85 | expect((await post(bob, "design", new URLSearchParams({ dial: "90" }))).status).toBe(404); | |
| 84 | expect(page.status).toBe(403); | |
| 85 | expect((await post(bob, "design", new URLSearchParams({ dial: "90" }))).status).toBe(403); | |
| 86 | 86 | expect(dialOf("design")).toBe(30); |
| 87 | 87 | expect((await post(alice, "design", new URLSearchParams({ dial: "5" }), "http://evil.example")).status).toBe(403); |
| 88 | 88 | expect(dialOf("design")).toBe(30); |
test/crew-week-card.test.ts +2 −2
| @@ -223,12 +223,12 @@ test("a change request by the pull's own author is not a return", () => { | ||
| 223 | 223 | expect(weekCard(app.db).find((l) => l.username === "smith")!.returned).toBe(before); |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | test("a non-admin: the page is not found and has no week block, the API and MCP refuse", async () => { | |
| 226 | test("a non-admin: the page is refused and has no week block, the API and MCP refuse", async () => { | |
| 227 | 227 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "pat", email: "[email protected]", password: "a-good-passphrase" }); |
| 228 | 228 | if (!u.ok) throw new Error(String(u.error)); |
| 229 | 229 | const pc = `forge_session=${createSession(app.db, u.value.id).token}`; |
| 230 | 230 | const page = await fetch(`${servers.url}/settings/crew`, { headers: { cookie: pc } }); |
| 231 | expect(page.status).toBe(404); | |
| 231 | expect(page.status).toBe(403); | |
| 232 | 232 | expect(await page.text()).not.toContain("crew-week"); |
| 233 | 233 | const pt = createToken(app.db, u.value.id, "t", "ro").token; |
| 234 | 234 | const api = await fetch(`${servers.url}/api/v1/crew/week`, { headers: { authorization: `Bearer ${pt}` } }); |
test/devices.test.ts +1 −1
| @@ -182,7 +182,7 @@ test("a signed-in non-admin sees nothing and can neither declare nor remove", as | ||
| 182 | 182 | if (!keep.ok) throw new Error(keep.error); |
| 183 | 183 | const before = listDevices(app.db).length; |
| 184 | 184 | const page = await fetch(`${servers.url}/settings/devices`, { headers: { cookie: plain } }); |
| 185 | expect(page.status).toBe(404); | |
| 185 | expect(page.status).toBe(403); | |
| 186 | 186 | expect(await page.text()).not.toContain("Keep"); |
| 187 | 187 | await post("/settings/devices", { name: "Mine", kind: "printer", url: printerUrl }, plain); |
| 188 | 188 | await post("/settings/devices/delete", { id: String(keep.value.id) }, plain); |
test/edge-gone.test.ts +1 −1
| @@ -169,7 +169,7 @@ describe("the forge with its forwardAuth edge gone", () => { | ||
| 169 | 169 | expect(mintWayBack(app, owner, "olga")).toEqual({ ok: false, error: "not_found" }); |
| 170 | 170 | const beaCookie = `forge_session=${createSession(app.db, bea.id).token}`; |
| 171 | 171 | const refused = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: beaCookie, origin: base } }); |
| 172 | expect(refused.status).toBe(404); | |
| 172 | expect(refused.status).toBe(403); | |
| 173 | 173 | // From another site: refused before anything is made. |
| 174 | 174 | const crossed = await fetch(`${base}/settings/accounts/ada/way-back`, { method: "POST", headers: { cookie: ownerCookie, origin: "https://elsewhere.example" } }); |
| 175 | 175 | expect(crossed.status).toBe(403); |
test/feature-run-now.test.ts +2 −2
| @@ -168,8 +168,8 @@ describe("the route", () => { | ||
| 168 | 168 | expect(page).toContain("Run now"); |
| 169 | 169 | }); |
| 170 | 170 | |
| 171 | test("a non-admin gets the 404", async () => { | |
| 172 | expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(404); | |
| 171 | test("a non-admin gets the 403", async () => { | |
| 172 | expect((await web(cyCookie, "/settings/features/radar/run/scan", true)).status).toBe(403); | |
| 173 | 173 | }); |
| 174 | 174 | |
| 175 | 175 | test("an admin on an asleep app is sent back with a sentence, and nothing is recorded", async () => { |
test/features.test.ts +2 −2
| @@ -276,8 +276,8 @@ describe("absorbing an app", () => { | ||
| 276 | 276 | expect((await api("GET", cyToken, "")).status).toBe(404); |
| 277 | 277 | // A read-scope token of the admin may look, not absorb. |
| 278 | 278 | expect((await api("PUT", adaReadToken, "/radar")).status).toBe(403); |
| 279 | expect((await web(cyCookie, "/settings/features")).status).toBe(404); | |
| 280 | expect((await web(cyCookie, "/settings/features/radar/absorb", new URLSearchParams())).status).toBe(404); | |
| 279 | expect((await web(cyCookie, "/settings/features")).status).toBe(403); | |
| 280 | expect((await web(cyCookie, "/settings/features/radar/absorb", new URLSearchParams())).status).toBe(403); | |
| 281 | 281 | expect(getAbsorbed(app.db, appId)).toBeUndefined(); |
| 282 | 282 | }); |
| 283 | 283 |
test/forge-sleep.test.ts +3 −3
| @@ -301,8 +301,8 @@ describe("a hosted forge scales to zero", () => { | ||
| 301 | 301 | headers: { authorization: `Bearer ${bearer}`, "content-type": "application/x-www-form-urlencoded", ...(origin === undefined ? {} : { origin }) }, |
| 302 | 302 | body: "", |
| 303 | 303 | }); |
| 304 | expect((await post("/forges/homie/stop", carolToken, "https://forge.test")).status).toBe(404); | |
| 305 | expect((await post("/forges/homie/remove", carolToken, "https://forge.test")).status).toBe(404); | |
| 304 | expect((await post("/forges/homie/stop", carolToken, "https://forge.test")).status).toBe(403); | |
| 305 | expect((await post("/forges/homie/remove", carolToken, "https://forge.test")).status).toBe(403); | |
| 306 | 306 | expect(existsSync(join(dir, "spawn", "homie.json"))).toBe(true); |
| 307 | 307 | const cross = await post("/forges/homie/remove", aliceToken, "https://evil.example"); |
| 308 | 308 | expect(cross.status).toBe(403); |
| @@ -311,7 +311,7 @@ describe("a hosted forge scales to zero", () => { | ||
| 311 | 311 | expect(confirm.status).toBe(200); |
| 312 | 312 | expect(await confirm.text()).toContain("cannot be undone"); |
| 313 | 313 | const asCarol = await fetch(`${servers.url}/forges/homie/remove`, { headers: { authorization: `Bearer ${carolToken}` } }); |
| 314 | expect(asCarol.status).toBe(404); | |
| 314 | expect(asCarol.status).toBe(403); | |
| 315 | 315 | const stopped = await post("/forges/homie/stop", aliceToken, "https://forge.test"); |
| 316 | 316 | expect(stopped.status).toBe(200); |
| 317 | 317 | }); |
test/gift-codes.test.ts +1 −1
| @@ -277,7 +277,7 @@ describe("gift codes", () => { | ||
| 277 | 277 | expect(text).toContain("Stop this code working"); |
| 278 | 278 | expect(await (await send("GET", "/settings/billing", admin)).text()).not.toContain(code); |
| 279 | 279 | expect((await form("/settings/billing/codes", admin, { plan: "free", uses: "1" })).status).toBe(422); |
| 280 | expect((await form("/settings/billing/codes", bob, { plan: "small" })).status).toBe(404); | |
| 280 | expect((await form("/settings/billing/codes", bob, { plan: "small" })).status).toBe(403); | |
| 281 | 281 | |
| 282 | 282 | const home = await send("GET", `/?code=${code}`, undefined); |
| 283 | 283 | expect(home.status).toBe(303); |
test/home-frame.test.ts +1 −1
| @@ -89,7 +89,7 @@ test("each late region answers alone, to its own viewer, and to no one else", as | ||
| 89 | 89 | } |
| 90 | 90 | for (const name of ["kpis", "panels"]) { |
| 91 | 91 | const peer = await fetch(`${f.base}/live/home/${name}`, { headers: { cookie: f.peerCookie } }); |
| 92 | expect(peer.status).toBe(404); | |
| 92 | expect(peer.status).toBe(403); | |
| 93 | 93 | } |
| 94 | 94 | const peerFeed = await fetch(`${f.base}/live/home/feed`, { headers: { cookie: f.peerCookie } }); |
| 95 | 95 | expect(peerFeed.status).toBe(200); |
test/knobs-page.test.ts +1 −1
| @@ -89,7 +89,7 @@ test("a member who is not an admin is refused and changes nothing", async () => | ||
| 89 | 89 | headers: { authorization: `Bearer ${token}`, "content-type": "application/x-www-form-urlencoded", origin: servers.url }, |
| 90 | 90 | body: new URLSearchParams({ knob: "speed", value: "6", repo: "" }), |
| 91 | 91 | }); |
| 92 | expect(res.status).toBe(404); | |
| 92 | expect(res.status).toBe(403); | |
| 93 | 93 | expect(capacityValue(app.db, "crew.concurrency")).toBe("3"); |
| 94 | 94 | }); |
| 95 | 95 |
test/loop-stall-by-route.test.ts +2 −2
| @@ -170,13 +170,13 @@ test("the stall section lists route, ms and count, the route escaped", () => { | ||
| 170 | 170 | expect(text).toContain('<td class="mono">/api/v1/telemetry/flow</td><td class="num mono">0</td><td class="num mono">0</td>'); |
| 171 | 171 | }); |
| 172 | 172 | |
| 173 | test("/system shows the owner the stall section with the flow route; a member gets 404", async () => { | |
| 173 | test("/system shows the owner the stall section with the flow route; a member gets 403", async () => { | |
| 174 | 174 | const page = await fetch(`${servers.url}/system`, { headers: { cookie: await login("alice") } }); |
| 175 | 175 | expect(page.status).toBe(200); |
| 176 | 176 | const html = await page.text(); |
| 177 | 177 | expect(html).toContain('id="sys-stalls"'); |
| 178 | 178 | expect(html).toContain('<tr data-route="/api/v1/telemetry/flow">'); |
| 179 | expect((await fetch(`${servers.url}/system`, { headers: { cookie: await login("bob") } })).status).toBe(404); | |
| 179 | expect((await fetch(`${servers.url}/system`, { headers: { cookie: await login("bob") } })).status).toBe(403); | |
| 180 | 180 | }); |
| 181 | 181 | |
| 182 | 182 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); |
test/maintenance.test.ts +2 −2
| @@ -139,11 +139,11 @@ describe("prepare and come back", () => { | ||
| 139 | 139 | expect(inMaintenance(dataDir)).toBe(false); |
| 140 | 140 | }); |
| 141 | 141 | |
| 142 | test("page: guest is sent to sign in, a non-admin gets not-found", async () => { | |
| 142 | test("page: guest is sent to sign in, a non-admin gets 403", async () => { | |
| 143 | 143 | const guest = await fetch(`${servers.url}/maintenance`, { redirect: "manual" }); |
| 144 | 144 | expect([302, 303, 401]).toContain(guest.status); |
| 145 | 145 | const b = await fetch(`${servers.url}/maintenance`, { headers: { cookie: await login("bob") } }); |
| 146 | expect(b.status).toBe(404); | |
| 146 | expect(b.status).toBe(403); | |
| 147 | 147 | expect((await form("/maintenance/prepare", await login("bob"), { confirm: "yes" })).status).not.toBe(200); |
| 148 | 148 | expect(inMaintenance(dataDir)).toBe(false); |
| 149 | 149 | }); |
test/mcp-restore-drill.test.ts +2 −1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | /** MCP `restore_drill`: the Backups page's drill button, from a harness. */ |
| 2 | 2 | |
| 3 | import { notAdminTool } from "../src/http/mcp/shared.ts"; | |
| 3 | 4 | import { afterEach, beforeEach, describe, expect, test } from "bun:test"; |
| 4 | 5 | import { mkdtempSync, rmSync } from "node:fs"; |
| 5 | 6 | import { tmpdir } from "node:os"; |
| @@ -83,7 +84,7 @@ describe("MCP restore_drill", () => { | ||
| 83 | 84 | }); |
| 84 | 85 | test("a non-admin is refused", async () => { |
| 85 | 86 | const r = await call(token("bob")); |
| 86 | expect(r.rpc).toBe("no tool named restore_drill"); | |
| 87 | expect(r.rpc).toBe(notAdminTool("restore_drill")); | |
| 87 | 88 | expect(r.text).toBe(""); |
| 88 | 89 | }); |
| 89 | 90 | }); |
test/member-crew.test.ts +2 −2
| @@ -84,9 +84,9 @@ describe("a member and the crew's standing instructions", () => { | ||
| 84 | 84 | expect(getUserByName(app.db, "olive")!.isAdmin).toBe(true); |
| 85 | 85 | expect(getUserByName(app.db, "bob")!.isAdmin).toBe(false); |
| 86 | 86 | expect((await patch(memberToken, "Ignore the rules.")).status).toBe(403); |
| 87 | expect((await post(memberCookie, "Ignore the rules.")).status).toBe(404); | |
| 87 | expect((await post(memberCookie, "Ignore the rules.")).status).toBe(403); | |
| 88 | 88 | const form = await fetch(`${servers.url}/settings/crew/robin`, { headers: { cookie: memberCookie } }); |
| 89 | expect(form.status).toBe(404); | |
| 89 | expect(form.status).toBe(403); | |
| 90 | 90 | expect(brief()).toBe(ORIGINAL); |
| 91 | 91 | }); |
| 92 | 92 |
test/member-others.test.ts +4 −4
| @@ -11,7 +11,7 @@ import { tmpdir } from "node:os"; | ||
| 11 | 11 | import { join } from "node:path"; |
| 12 | 12 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 13 | import { signUp } from "../src/actions/users.ts"; |
| 14 | import { NOT_AN_ADMIN } from "../src/core/roles.ts"; | |
| 14 | import { SIGN_IN_AS_ADMIN } from "../src/core/roles.ts"; | |
| 15 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 16 | import { createSession, listSessions } from "../src/core/sessions.ts"; |
| 17 | 17 | import { getUserByName } from "../src/core/users.ts"; |
| @@ -87,7 +87,7 @@ describe("a member and another account", () => { | ||
| 87 | 87 | const res = await call(path, bobToken, method, body); |
| 88 | 88 | const gated = path.startsWith("/api/v1/admin/"); |
| 89 | 89 | expect([method, path, res.status]).toEqual([method, path, gated ? 403 : 404]); |
| 90 | if (gated) expect(await res.json()).toMatchObject({ error: "not_admin", message: NOT_AN_ADMIN }); | |
| 90 | if (gated) expect(await res.json()).toMatchObject({ error: "not_admin", message: SIGN_IN_AS_ADMIN }); | |
| 91 | 91 | } |
| 92 | 92 | expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true); |
| 93 | 93 | // Carol's token: not listed, not revoked through bob's own token routes. |
| @@ -122,9 +122,9 @@ describe("a member and another account", () => { | ||
| 122 | 122 | [`/settings/accounts/carol/sessions/${sid}/delete`, {}], |
| 123 | 123 | ]; |
| 124 | 124 | for (const [path, form] of forms) |
| 125 | expect([path, (await post(path, form)).status]).toEqual([path, 404]); | |
| 125 | expect([path, (await post(path, form)).status]).toEqual([path, 403]); | |
| 126 | 126 | const page = await fetch(`${servers.url}/settings/accounts/carol`, { headers: { cookie } }); |
| 127 | expect(page.status).toBe(404); | |
| 127 | expect(page.status).toBe(403); | |
| 128 | 128 | expect((await call("/api/v1/user", laptop.token)).status).toBe(200); |
| 129 | 129 | expect(listSessions(app.db, carol.id).some((s) => s.id === sid)).toBe(true); |
| 130 | 130 | expect(JSON.stringify(getUserByName(app.db, "carol"))).toBe(carolBefore); |
test/model-grant-pages.test.ts +3 −3
| @@ -72,7 +72,7 @@ describe("a model grant, from the account's page and the API", () => { | ||
| 72 | 72 | body: new URLSearchParams({ usd: "20" }), |
| 73 | 73 | redirect: "manual", |
| 74 | 74 | }); |
| 75 | expect(refused.status).toBe(404); | |
| 75 | expect(refused.status).toBe(403); | |
| 76 | 76 | expect(getModelGrant(app.db, getUserByName(app.db, "bob")!.id)).toBeUndefined(); |
| 77 | 77 | |
| 78 | 78 | const admin = await signIn("alice"); |
| @@ -118,7 +118,7 @@ describe("a model grant, from the account's page and the API", () => { | ||
| 118 | 118 | expect(getModelGrant(app.db, carol.id)).toBeUndefined(); |
| 119 | 119 | }); |
| 120 | 120 | |
| 121 | test("the ceiling route: signed out, a person's 404, a bad amount's flash, saved; the card shows it", async () => { | |
| 121 | test("the ceiling route: signed out, a person's 403, a bad amount's flash, saved; the card shows it", async () => { | |
| 122 | 122 | const url = `${servers.url}/settings/accounts/carol/ceiling`; |
| 123 | 123 | const send = (cookie: string | undefined, daily: string, perRun: string) => |
| 124 | 124 | fetch(url, { |
| @@ -132,7 +132,7 @@ describe("a model grant, from the account's page and the API", () => { | ||
| 132 | 132 | const bob = await signIn("bob"); |
| 133 | 133 | expect((await send(undefined, "5", "1")).status).not.toBe(200); |
| 134 | 134 | expect(getAccountCeiling(app.db, carol.id)).toBeUndefined(); |
| 135 | expect((await send(bob, "5", "1")).status).toBe(404); | |
| 135 | expect((await send(bob, "5", "1")).status).toBe(403); | |
| 136 | 136 | expect(getAccountCeiling(app.db, carol.id)).toBeUndefined(); |
| 137 | 137 | const bad = await send(alice, "abc", "1"); |
| 138 | 138 | expect(bad.status).toBe(303); |
test/model-language.test.ts +1 −1
| @@ -81,7 +81,7 @@ test("a ceiling still posts for an admin only, behind the same-origin check", as | ||
| 81 | 81 | const asBob = await fetch(url, { |
| 82 | 82 | method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.bob!, origin: servers.url }, |
| 83 | 83 | }); |
| 84 | expect(asBob.status).toBe(404); | |
| 84 | expect(asBob.status).toBe(403); | |
| 85 | 85 | const foreign = await fetch(url, { |
| 86 | 86 | method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: "https://evil.test" }, |
| 87 | 87 | }); |
test/new-forge.test.ts +2 −2
| @@ -107,7 +107,7 @@ describe("+ New forge", () => { | ||
| 107 | 107 | 'href="/forges/new"', |
| 108 | 108 | ); |
| 109 | 109 | expect((await get(reader, "/forges/new")).status).toBe(200); |
| 110 | expect((await get(reader, "/forges")).status).toBe(404); | |
| 110 | expect((await get(reader, "/forges")).status).toBe(403); | |
| 111 | 111 | expect( |
| 112 | 112 | ( |
| 113 | 113 | await fetch(url("/forges/kai/claim-link"), { |
| @@ -116,7 +116,7 @@ describe("+ New forge", () => { | ||
| 116 | 116 | redirect: "manual", |
| 117 | 117 | }) |
| 118 | 118 | ).status, |
| 119 | ).toBe(404); | |
| 119 | ).toBe(403); | |
| 120 | 120 | expect(await children()).toBe(0); |
| 121 | 121 | }); |
| 122 | 122 |
test/nightly-output.test.ts +2 −2
| @@ -167,8 +167,8 @@ describe("the page a nightly issue links to", () => { | ||
| 167 | 167 | expect(await res.text()).toContain("No output here yet"); |
| 168 | 168 | }); |
| 169 | 169 | |
| 170 | test("anyone but an admin, and any job that is not one, is not found", async () => { | |
| 171 | expect((await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie: await login("bob") } })).status).toBe(404); | |
| 170 | test("anyone but an admin is refused, and any job that is not one is not found", async () => { | |
| 171 | expect((await fetch(`${servers.url}${runPath("outside-gate")}`, { headers: { cookie: await login("bob") } })).status).toBe(403); | |
| 172 | 172 | expect((await fetch(`${servers.url}${runPath("sync-env")}`, { headers: { cookie: await login("alice") } })).status).toBe(404); |
| 173 | 173 | const anon = await fetch(`${servers.url}${runPath("outside-gate")}`, { redirect: "manual" }); |
| 174 | 174 | expect(anon.status).not.toBe(200); |
test/packs-curated.test.ts +1 −1
| @@ -86,7 +86,7 @@ test("an unknown op is refused and says so, and changes nothing", async () => { | ||
| 86 | 86 | test("only an admin sees Edit; a non-admin's and a cross-origin edit are refused", async () => { |
| 87 | 87 | expect(await text("/packs/gamer", plain)).not.toContain("Edit this pack"); |
| 88 | 88 | expect(await text("/packs/gamer", admin)).toContain("Edit this pack"); |
| 89 | expect((await post("/packs/gamer/repos", plain, { op: "remove", repo: "trevato/chess" })).status).toBe(404); | |
| 89 | expect((await post("/packs/gamer/repos", plain, { op: "remove", repo: "trevato/chess" })).status).toBe(403); | |
| 90 | 90 | expect((await post("/packs/gamer/repos", admin, { op: "remove", repo: "trevato/chess" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400); |
| 91 | 91 | expect(getPack(app.db, "gamer")!.repos).toContain("trevato/chess"); |
| 92 | 92 | }); |
test/packs-settings.test.ts +1 −1
| @@ -53,7 +53,7 @@ test("nothing is marked, so /packs lists no pack even though the repos exist", a | ||
| 53 | 53 | }); |
| 54 | 54 | |
| 55 | 55 | test("only an admin sees the page or saves; a cross-origin post is refused", async () => { |
| 56 | expect((await fetch(`${servers.url}/settings/packs`, { headers: { cookie: plain } })).status).toBe(404); | |
| 56 | expect((await fetch(`${servers.url}/settings/packs`, { headers: { cookie: plain } })).status).toBe(403); | |
| 57 | 57 | expect((await save(plain, "packs/games")).status).not.toBe(303); |
| 58 | 58 | expect((await save(admin, "packs/games", "https://evil.example")).status).toBeGreaterThanOrEqual(400); |
| 59 | 59 | expect(markedRepos(app.db)).toEqual([]); |
test/people-timing.test.ts +3 −3
| @@ -60,7 +60,7 @@ test("GET /settings/people names its phases", async () => { | ||
| 60 | 60 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 61 | 61 | }); |
| 62 | 62 | |
| 63 | test("a non-admin gets the 404 without the admin phases", async () => { | |
| 63 | test("a non-admin gets the 403 without the admin phases", async () => { | |
| 64 | 64 | const b = await createUser(app.db, { |
| 65 | 65 | origin: "seed", |
| 66 | 66 | trusted: true, |
| @@ -72,7 +72,7 @@ test("a non-admin gets the 404 without the admin phases", async () => { | ||
| 72 | 72 | proveAll(app); |
| 73 | 73 | const cookie = await signIn("bob"); |
| 74 | 74 | const get = await fetch(`${servers.url}/settings/people`, { headers: { cookie } }); |
| 75 | expect(get.status).toBe(404); | |
| 75 | expect(get.status).toBe(403); | |
| 76 | 76 | for (const name of ["people", "render", "role"]) expect(phase(get, name)).toBeUndefined(); |
| 77 | 77 | const post = await fetch(`${servers.url}/settings/people/alice/role`, { |
| 78 | 78 | method: "POST", |
| @@ -80,7 +80,7 @@ test("a non-admin gets the 404 without the admin phases", async () => { | ||
| 80 | 80 | body: new URLSearchParams({ role: "guest" }), |
| 81 | 81 | redirect: "manual", |
| 82 | 82 | }); |
| 83 | expect(post.status).toBe(404); | |
| 83 | expect(post.status).toBe(403); | |
| 84 | 84 | for (const name of ["session", "role"]) expect(phase(post, name)).toBeUndefined(); |
| 85 | 85 | }); |
| 86 | 86 |
test/pinned-page.test.ts +5 −5
| @@ -80,8 +80,8 @@ describe("deploy-to page", () => { | ||
| 80 | 80 | }); |
| 81 | 81 | |
| 82 | 82 | test("someone who is not an admin finds nothing", async () => { |
| 83 | expect((await get("/deploy-to", bobTok)).status).toBe(404); | |
| 84 | expect((await post(good, bobTok)).status).toBe(404); | |
| 83 | expect((await get("/deploy-to", bobTok)).status).toBe(403); | |
| 84 | expect((await post(good, bobTok)).status).toBe(403); | |
| 85 | 85 | expect(getPinned(app.db, "acme")).toBeUndefined(); |
| 86 | 86 | }); |
| 87 | 87 | |
| @@ -109,7 +109,7 @@ describe("deploy-to page", () => { | ||
| 109 | 109 | }); |
| 110 | 110 | |
| 111 | 111 | describe("pinned list page", () => { |
| 112 | test("renders none, then one forge; a non-admin gets 404", async () => { | |
| 112 | test("renders none, then one forge; a non-admin gets 403", async () => { | |
| 113 | 113 | const dataDir = await mkdtemp(join(tmpdir(), "forge-pinned-list-")); |
| 114 | 114 | const app = createApp({ dataDir, port: 0 }); |
| 115 | 115 | const servers = await serve(app); |
| @@ -122,7 +122,7 @@ describe("pinned list page", () => { | ||
| 122 | 122 | const bobTok = createToken(app.db, getUserByName(app.db, "bob")!.id, "admin", "admin").token; |
| 123 | 123 | const get = (tok?: string) => fetch(servers.url + "/pinned", { redirect: "manual", headers: tok ? { authorization: `Bearer ${tok}` } : {} }); |
| 124 | 124 | expect((await get()).status).toBe(303); |
| 125 | expect((await get(bobTok)).status).toBe(404); | |
| 125 | expect((await get(bobTok)).status).toBe(403); | |
| 126 | 126 | const empty = await (await get(adminTok)).text(); |
| 127 | 127 | expect(empty).toContain("No pinned forge yet"); |
| 128 | 128 | expect(empty).toContain('href="/deploy-to"'); |
| @@ -204,7 +204,7 @@ describe("pinned list page actions", () => { | ||
| 204 | 204 | expect((await get("/-/confirm?to=/pinned/Bad_Name/update", adminTok)).status).toBe(404); |
| 205 | 205 | |
| 206 | 206 | // Not an admin: 404 and nothing moves. Cross-origin: 403. |
| 207 | for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(404); | |
| 207 | for (const a of ["update", "rollback", "check"]) expect((await act(`/pinned/acme/${a}`, bobTok, { version: V2 })).status).toBe(403); | |
| 208 | 208 | expect((await act("/pinned/acme/update", adminTok, { version: V2 }, "https://evil.example")).status).toBe(403); |
| 209 | 209 | expect(getPinned(app.db, "acme")!.version).toBe(V); |
| 210 | 210 |
test/plan-wiring.test.ts +1 −1
| @@ -114,7 +114,7 @@ test("the portal setting refuses a member, an agent and a cross-origin post; not | ||
| 114 | 114 | fetch(`${servers.url}/settings/billing/portal`, { method: "POST", headers: { cookie, origin, accept: "text/html" }, body: new URLSearchParams({ portal_url: PORTAL }), redirect: "manual" }); |
| 115 | 115 | const mine = `forge_session=${createSession(app.db, member.id).token}`; |
| 116 | 116 | const theirs = `forge_session=${createSession(app.db, admin.id).token}`; |
| 117 | expect((await post(mine, servers.url)).status).toBe(404); | |
| 117 | expect((await post(mine, servers.url)).status).toBe(403); | |
| 118 | 118 | expect((await post(theirs, "https://evil.test")).status).toBe(403); |
| 119 | 119 | expect(getPortalUrl(app.db)).toBe(before); |
| 120 | 120 | expect((await post(theirs, servers.url)).status).toBe(303); |
test/print.test.ts +1 −1
| @@ -332,7 +332,7 @@ test("only an admin sets the cap, and setting it appends device.cap_set", async | ||
| 332 | 332 | body: new URLSearchParams({ id: String(deviceId), app: "racer", cap: "7" }), |
| 333 | 333 | redirect: "manual", |
| 334 | 334 | }); |
| 335 | expect(res.status).toBe(404); | |
| 335 | expect(res.status).toBe(403); | |
| 336 | 336 | expect(cap()).toBe(1); |
| 337 | 337 | const before = app.db.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM event WHERE kind = 'device.cap_set'").get()!.n; |
| 338 | 338 | expect(setCap(app, owner, deviceId, "racer", 7).ok).toBe(true); |
test/resilience-page.test.ts +1 −1
| @@ -80,7 +80,7 @@ describe("Settings → Backups → Failure domains", () => { | ||
| 80 | 80 | const bob = await fetch(`${servers.url}/settings/backups`, { |
| 81 | 81 | headers: { cookie: await login("bob") }, |
| 82 | 82 | }); |
| 83 | expect(bob.status).toBe(404); | |
| 83 | expect(bob.status).toBe(403); | |
| 84 | 84 | expect(await bob.text()).not.toContain("Failure domains"); |
| 85 | 85 | const anon = await fetch(`${servers.url}/settings/backups`, { redirect: "manual" }); |
| 86 | 86 | expect(anon.status).toBe(303); |
test/resilience.test.ts +2 −2
| @@ -120,7 +120,7 @@ describe("resilience", () => { | ||
| 120 | 120 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 121 | 121 | }; |
| 122 | 122 | |
| 123 | test("a guest is sent to sign in, a non-admin gets 404, an admin sees the rows", async () => { | |
| 123 | test("a guest is sent to sign in, a non-admin gets 403, an admin sees the rows", async () => { | |
| 124 | 124 | for (const [name, admin] of [["alice", true], ["bob", false]] as const) { |
| 125 | 125 | const made = await createUser(app.db, { |
| 126 | 126 | origin: "seed", |
| @@ -139,7 +139,7 @@ describe("resilience", () => { | ||
| 139 | 139 | expect(guest.status).not.toBe(200); |
| 140 | 140 | expect(guest.status).not.toBe(404); |
| 141 | 141 | const bob = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("bob") } }); |
| 142 | expect(bob.status).toBe(404); | |
| 142 | expect(bob.status).toBe(403); | |
| 143 | 143 | expect(await bob.text()).not.toContain("data-standing"); |
| 144 | 144 | |
| 145 | 145 | const res = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login("alice") } }); |
test/roles.test.ts +2 −2
| @@ -85,7 +85,7 @@ describe("roles on /settings/people", () => { | ||
| 85 | 85 | expect(html).toContain("bob"); |
| 86 | 86 | expect(html).toContain("Make member"); |
| 87 | 87 | const bob = await signIn("bob"); |
| 88 | expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(404); | |
| 88 | expect((await fetch(`${servers.url}/settings/people`, { headers: { cookie: bob } })).status).toBe(403); | |
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | 91 | test("the account page shows the caller's own row of the table", async () => { |
| @@ -124,7 +124,7 @@ describe("roles on /settings/people", () => { | ||
| 124 | 124 | |
| 125 | 125 | test("a non-admin, a foreign origin, an admin target and an unknown role are refused", async () => { |
| 126 | 126 | const bob = await signIn("bob"); |
| 127 | expect((await post(bob, "bob", { role: "member" })).status).toBe(404); | |
| 127 | expect((await post(bob, "bob", { role: "member" })).status).toBe(403); | |
| 128 | 128 | expect(roleNow("bob")).toBe("guest"); |
| 129 | 129 | const alice = await signIn("alice"); |
| 130 | 130 | expect((await post(alice, "bob", { role: "member" }, "https://evil.example")).status).toBeGreaterThanOrEqual(400); |
test/route-marks.test.ts added +261
| @@ -0,0 +1,261 @@ | ||
| 1 | /** | |
| 2 | * Every admin-only action refuses the everyday account (#11818). Both routers | |
| 3 | * are walked: each route must be marked `admin` or `open` in | |
| 4 | * `src/http/route_marks.ts`, so a new route has to choose. Each route marked | |
| 5 | * `admin` is called with an everyday (member) account and with the admin: | |
| 6 | * the first is refused with a 403 and the one sentence that says to sign in | |
| 7 | * as admin, the second is let through to its handler. Each MCP tool marked | |
| 8 | * `adminOnly` is called by the everyday account and refused with the same | |
| 9 | * sentence, and is listed to the admin. | |
| 10 | */ | |
| 11 | ||
| 12 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 13 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 14 | import { tmpdir } from "node:os"; | |
| 15 | import { join } from "node:path"; | |
| 16 | import { createApp, type App } from "../src/app.ts"; | |
| 17 | import { SIGN_IN_AS_ADMIN, setMember } from "../src/core/roles.ts"; | |
| 18 | import { createSession } from "../src/core/sessions.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, type User } from "../src/core/users.ts"; | |
| 21 | import { TOOLS } from "../src/http/mcp/tools.ts"; | |
| 22 | import { mintViaToken } from "../src/core/via_token.ts"; | |
| 23 | import { readFenced, waitingFor } from "../src/core/fence.ts"; | |
| 24 | import { HEADS, ROUTES, headOf, isAdminRoute, markOf } from "../src/http/route_marks.ts"; | |
| 25 | import { buildApiRouter, buildRouter, forgeHttp, type ForgeHttp } from "../src/http/server.ts"; | |
| 26 | import { quietLogs } from "./helpers/globals.ts"; | |
| 27 | ||
| 28 | quietLogs(); | |
| 29 | ||
| 30 | const FILL: Record<string, string> = { | |
| 31 | owner: "alice", username: "alice", name: "notes", number: "1", org: "alice", | |
| 32 | id: "1", team: "core", scope: "alice", job: "1", oid: "0", env: "prod", sha: "a".repeat(40), | |
| 33 | }; | |
| 34 | const fill = (pattern: string): string => | |
| 35 | pattern.replace(/:([a-z_]+)/g, (_, k: string) => FILL[k] ?? "x").replace(/\*[a-z_]+/, "x"); | |
| 36 | ||
| 37 | /** Admin pages with nothing in the path to fill that still answer 4xx on a bare forge, each with what it waits for. */ | |
| 38 | const ADMIN_GET_WANTS: Record<string, string> = { | |
| 39 | "GET /settings/traffic/scrapes/export": "a running capture and its ?group, else not found", | |
| 40 | "GET /settings/backups/live": "a snapshot store, which a bare forge has not started", | |
| 41 | "GET /api/v1/admin/profile": "a query naming what to profile, else 422", | |
| 42 | }; | |
| 43 | ||
| 44 | const routes = () => [ | |
| 45 | ...buildRouter().routes().map((r) => ({ ...r, api: false })), | |
| 46 | ...buildApiRouter().routes().map((r) => ({ ...r, api: true })), | |
| 47 | ]; | |
| 48 | ||
| 49 | describe("the route marks", () => { | |
| 50 | test("every registered route is marked admin or open", () => { | |
| 51 | const unmarked = routes().filter((r) => markOf(r.pattern) === undefined).map((r) => `${r.method} ${r.pattern}`); | |
| 52 | expect(unmarked).toEqual([]); | |
| 53 | }); | |
| 54 | ||
| 55 | test("a route marked neither fails: an unknown head, or a new route under a mixed head", () => { | |
| 56 | expect(markOf("/brand-new-page")).toBeUndefined(); | |
| 57 | expect(markOf("/api/v1/brand-new")).toBeUndefined(); | |
| 58 | expect(HEADS["/settings"]).toBe("mixed"); | |
| 59 | expect(markOf("/settings/brand-new")).toBeUndefined(); | |
| 60 | }); | |
| 61 | ||
| 62 | test("the marks name only registered routes and heads, so neither list goes stale", () => { | |
| 63 | const patterns = new Set(routes().map((r) => r.pattern)); | |
| 64 | expect(Object.keys(ROUTES).filter((p) => !patterns.has(p))).toEqual([]); | |
| 65 | const heads = new Set([...patterns].map((p) => (p.startsWith("/api/") ? p.split("/").slice(0, 4).join("/") : `/${p.split("/")[1]}`))); | |
| 66 | expect(Object.keys(HEADS).filter((h) => !heads.has(h))).toEqual([]); | |
| 67 | }); | |
| 68 | ||
| 69 | test("each route whose admin side is not called is a registered admin route", () => { | |
| 70 | const admin = new Set(routes().filter((r) => markOf(r.pattern) === "admin").map((r) => `${r.method} ${r.pattern}`)); | |
| 71 | expect(Object.keys(ADMIN_GET_WANTS).filter((k) => !admin.has(k))).toEqual([]); | |
| 72 | }); | |
| 73 | ||
| 74 | test("a head holding both admin and open routes is mixed, so each of its routes chooses", () => { | |
| 75 | const exceptions = Object.entries(ROUTES).filter(([p, m]) => { | |
| 76 | const head = HEADS[headOf(p)]; | |
| 77 | return head !== "mixed" && head !== m; | |
| 78 | }); | |
| 79 | expect(exceptions.map(([p]) => p)).toEqual([]); | |
| 80 | }); | |
| 81 | ||
| 82 | test("an unlisted path under a mixed head is admin: a forgotten mark fails closed", () => { | |
| 83 | expect(isAdminRoute("/settings/brand-new")).toBe(true); | |
| 84 | expect(isAdminRoute("/admin/brand-new")).toBe(true); | |
| 85 | expect(isAdminRoute("/boards/a/b/c/d")).toBe(true); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a path falls on its pattern's mark, literal before parameter", () => { | |
| 89 | expect(isAdminRoute("/packs/new")).toBe(true); | |
| 90 | expect(isAdminRoute("/packs/someone")).toBe(false); | |
| 91 | expect(isAdminRoute("/settings/billing")).toBe(true); | |
| 92 | expect(isAdminRoute("/settings/tokens")).toBe(false); | |
| 93 | expect(isAdminRoute("/admin/flows")).toBe(false); | |
| 94 | expect(isAdminRoute("/api/v1/admin/capacity")).toBe(true); | |
| 95 | expect(isAdminRoute("/alice/notes")).toBe(false); | |
| 96 | }); | |
| 97 | ||
| 98 | test("the refusal is one sentence that says to sign in as admin", () => { | |
| 99 | expect(SIGN_IN_AS_ADMIN).toContain("sign in as admin"); | |
| 100 | expect(SIGN_IN_AS_ADMIN.match(/[.!?]/g)?.length).toBe(1); | |
| 101 | expect(SIGN_IN_AS_ADMIN.endsWith(".")).toBe(true); | |
| 102 | }); | |
| 103 | }); | |
| 104 | ||
| 105 | describe("every admin-only action refuses the everyday account", () => { | |
| 106 | let dataDir = ""; | |
| 107 | let app: App; | |
| 108 | let http: ForgeHttp; | |
| 109 | let alice: User; // admin | |
| 110 | let carol: User; // everyday: a member, no `administer` | |
| 111 | const cells = process.env["FORGE_CELLS"]; | |
| 112 | ||
| 113 | beforeAll(async () => { | |
| 114 | process.env["FORGE_CELLS"] = "off"; | |
| 115 | dataDir = await mkdtemp(join(tmpdir(), "forge-route-marks-")); | |
| 116 | app = createApp({ dataDir, port: 0 }); | |
| 117 | const mk = async (username: string, isAdmin: boolean) => { | |
| 118 | const r = await createUser(app.db, { | |
| 119 | origin: "seed", trusted: true, isAdmin, username, email: `${username}@forge.local`, password: "a-good-passphrase", | |
| 120 | }); | |
| 121 | if (!r.ok) throw new Error(r.error); | |
| 122 | return r.value; | |
| 123 | }; | |
| 124 | alice = await mk("alice", true); | |
| 125 | carol = await mk("carol", false); | |
| 126 | setMember(app.db, carol.id, true, alice.id, new Date(0)); | |
| 127 | http = forgeHttp(app); | |
| 128 | }, 60_000); | |
| 129 | ||
| 130 | afterAll(async () => { | |
| 131 | app.close(); | |
| 132 | await rm(dataDir, { recursive: true, force: true }); | |
| 133 | if (cells === undefined) delete process.env["FORGE_CELLS"]; | |
| 134 | else process.env["FORGE_CELLS"] = cells; | |
| 135 | }); | |
| 136 | ||
| 137 | const creds = new Map<string, { bearer: string; cookie: string }>(); | |
| 138 | const credsOf = (u: User) => { | |
| 139 | const known = creds.get(u.id); | |
| 140 | if (known !== undefined) return known; | |
| 141 | const made = { | |
| 142 | bearer: `Bearer ${createToken(app.db, u.id, "t", "admin").token}`, | |
| 143 | cookie: `forge_session=${createSession(app.db, u.id).token}`, | |
| 144 | }; | |
| 145 | creds.set(u.id, made); | |
| 146 | return made; | |
| 147 | }; | |
| 148 | /** A refusal's status and words; a stream the gate let through is not read, only its status. */ | |
| 149 | const send = async (u: User, method: string, pattern: string, api: boolean) => { | |
| 150 | const { bearer, cookie } = credsOf(u); | |
| 151 | const headers: Record<string, string> = api | |
| 152 | ? { authorization: bearer, "content-type": "application/json" } | |
| 153 | : { cookie, accept: "text/html", "content-type": "application/json" }; | |
| 154 | if (method !== "GET") headers["origin"] = "http://forge.test"; | |
| 155 | const res = await http.handle( | |
| 156 | new Request(`http://forge.test${fill(pattern)}`, { method, headers, ...(method === "GET" ? {} : { body: "{}" }) }), | |
| 157 | ); | |
| 158 | if (res.status !== 403) { | |
| 159 | await res.body?.cancel(); | |
| 160 | return { status: res.status, text: "" }; | |
| 161 | } | |
| 162 | return { status: res.status, text: await res.text() }; | |
| 163 | }; | |
| 164 | const refusedKindly = (r: { status: number; text: string }, api: boolean): boolean => | |
| 165 | r.status === 403 && | |
| 166 | (api ? JSON.parse(r.text).message === SIGN_IN_AS_ADMIN : r.text.includes(SIGN_IN_AS_ADMIN.replace("'", "'"))); | |
| 167 | ||
| 168 | test("each admin route refuses the everyday account with the sentence, and lets the admin through", async () => { | |
| 169 | const admin = routes().filter((r) => markOf(r.pattern) === "admin"); | |
| 170 | expect(admin.length).toBeGreaterThan(150); | |
| 171 | const wrong: string[] = []; | |
| 172 | for (const r of admin) { | |
| 173 | const everyday = await send(carol, r.method, r.pattern, r.api); | |
| 174 | if (!refusedKindly(everyday, r.api)) wrong.push(`${r.method} ${r.pattern}: everyday got ${everyday.status}`); | |
| 175 | const own = await send(alice, r.method, r.pattern, r.api); | |
| 176 | if (refusedKindly(own, r.api) || own.status >= 500) wrong.push(`${r.method} ${r.pattern}: admin got ${own.status}`); | |
| 177 | // A page with nothing to fill in answers the admin outright. | |
| 178 | if (r.method === "GET" && !r.pattern.includes(":") && own.status >= 400 && !(`GET ${r.pattern}` in ADMIN_GET_WANTS)) | |
| 179 | wrong.push(`${r.method} ${r.pattern}: admin got ${own.status}`); | |
| 180 | } | |
| 181 | expect(wrong).toEqual([]); | |
| 182 | }, 120_000); | |
| 183 | ||
| 184 | const mcp = async (u: User, method: string, params: Record<string, unknown>, token?: string) => { | |
| 185 | const res = await http.handle( | |
| 186 | new Request("http://forge.test/mcp", { | |
| 187 | method: "POST", | |
| 188 | headers: { | |
| 189 | "content-type": "application/json", | |
| 190 | accept: "application/json, text/event-stream", | |
| 191 | authorization: `Bearer ${token ?? createToken(app.db, u.id, "t", "admin").token}`, | |
| 192 | }, | |
| 193 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), | |
| 194 | }), | |
| 195 | ); | |
| 196 | return (await res.json()) as { | |
| 197 | result?: { tools?: { name: string }[]; isError?: boolean; content?: { text: string }[] }; | |
| 198 | error?: { message: string }; | |
| 199 | }; | |
| 200 | }; | |
| 201 | ||
| 202 | test("each admin MCP tool refuses the everyday account with the sentence, and is listed to the admin", async () => { | |
| 203 | const adminTools = TOOLS.filter((t) => t.adminOnly === true); | |
| 204 | expect(adminTools.length).toBeGreaterThan(5); | |
| 205 | const listed = async (u: User) => { | |
| 206 | const names = new Set<string>(); | |
| 207 | let cursor: string | undefined; | |
| 208 | do { | |
| 209 | const page = (await mcp(u, "tools/list", cursor === undefined ? {} : { cursor })) as { | |
| 210 | result?: { tools?: { name: string }[]; nextCursor?: string }; | |
| 211 | }; | |
| 212 | for (const t of page.result?.tools ?? []) names.add(t.name); | |
| 213 | cursor = page.result?.nextCursor; | |
| 214 | } while (cursor !== undefined); | |
| 215 | return names; | |
| 216 | }; | |
| 217 | const forAdmin = await listed(alice); | |
| 218 | const forEveryday = await listed(carol); | |
| 219 | const wrong: string[] = []; | |
| 220 | for (const t of adminTools) { | |
| 221 | const called = await mcp(carol, "tools/call", { name: t.name, arguments: {} }); | |
| 222 | if (!called.error?.message.endsWith(SIGN_IN_AS_ADMIN)) wrong.push(`${t.name}: everyday got ${JSON.stringify(called)}`); | |
| 223 | if (forEveryday.has(t.name)) wrong.push(`${t.name}: listed to the everyday account`); | |
| 224 | if (!forAdmin.has(t.name)) wrong.push(`${t.name}: not listed to the admin`); | |
| 225 | } | |
| 226 | expect(wrong).toEqual([]); | |
| 227 | }); | |
| 228 | ||
| 229 | test("the gate marks by the route the router matched: a git path under an owner named like a mixed head is open", async () => { | |
| 230 | const res = await send(carol, "GET", "/settings/demo.git/info/refs", false); | |
| 231 | expect(res.status).not.toBe(403); | |
| 232 | expect((await send(carol, "GET", "/settings/no-such-page", false)).status).toBe(404); | |
| 233 | }); | |
| 234 | ||
| 235 | test("the admin calls each read-only admin MCP tool and it answers; the write ones stay listed only", async () => { | |
| 236 | const reads = TOOLS.filter((t) => t.adminOnly === true && !t.writes); | |
| 237 | expect(reads.length).toBeGreaterThan(3); | |
| 238 | const wrong: string[] = []; | |
| 239 | for (const t of reads) { | |
| 240 | const called = await mcp(alice, "tools/call", { name: t.name, arguments: {} }); | |
| 241 | if (called.error !== undefined || called.result?.isError === true) | |
| 242 | wrong.push(`${t.name}: ${JSON.stringify(called).slice(0, 200)}`); | |
| 243 | } | |
| 244 | expect(wrong).toEqual([]); | |
| 245 | }); | |
| 246 | ||
| 247 | test("an everyday account's agent calling an admin MCP tool is refused before anything is held or asked", async () => { | |
| 248 | const via = "conversation:routemarks"; | |
| 249 | const minted = mintViaToken(app.db, carol.id, via, "", new Date(Date.now() + 10 * 60_000).toISOString()); | |
| 250 | if (!minted.ok) throw new Error(minted.error); | |
| 251 | // The agent has read fenced text, so a write it was not asked for would be held, were the call let that far. | |
| 252 | readFenced(app.db, via); | |
| 253 | const wrong: string[] = []; | |
| 254 | for (const t of TOOLS.filter((t) => t.adminOnly === true)) { | |
| 255 | const called = await mcp(carol, "tools/call", { name: t.name, arguments: {} }, minted.value.token); | |
| 256 | if (!called.error?.message.endsWith(SIGN_IN_AS_ADMIN)) wrong.push(`${t.name}: ${JSON.stringify(called).slice(0, 200)}`); | |
| 257 | } | |
| 258 | expect(wrong).toEqual([]); | |
| 259 | expect(waitingFor(app.db, carol.id)).toEqual([]); | |
| 260 | }); | |
| 261 | }); |
test/scrape-evidence.test.ts +2 −2
| @@ -137,7 +137,7 @@ test("the page, capture, import and export are an admin's and need a same-origin | ||
| 137 | 137 | const cookie = `forge_session=${createSession(app.db, admin.id).token}`; |
| 138 | 138 | const other = `forge_session=${createSession(app.db, bob.id).token}`; |
| 139 | 139 | const url = `${servers.url}/settings/traffic/scrapes`; |
| 140 | expect((await fetch(url, { headers: { cookie: other } })).status).toBe(404); | |
| 140 | expect((await fetch(url, { headers: { cookie: other } })).status).toBe(403); | |
| 141 | 141 | const page = await fetch(url, { headers: { cookie } }); |
| 142 | 142 | expect(page.status).toBe(200); |
| 143 | 143 | const text = await page.text(); |
| @@ -167,7 +167,7 @@ test("the page, capture, import and export are an admin's and need a same-origin | ||
| 167 | 167 | const reportText = await report.text(); |
| 168 | 168 | expect(reportText).toContain("[email protected]"); |
| 169 | 169 | expect(reportText).toContain("Limits:"); |
| 170 | expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie: other } })).status).toBe(404); | |
| 170 | expect((await fetch(`${url}/export?group=${id}`, { headers: { cookie: other } })).status).toBe(403); | |
| 171 | 171 | |
| 172 | 172 | expect((await post("stop", "")).status).toBe(303); |
| 173 | 173 | expect(captureStore.current(Date.now())).toBeNull(); |
test/secret-store.test.ts +1 −1
| @@ -317,7 +317,7 @@ describe("forge-level secrets", () => { | ||
| 317 | 317 | "Signup has no human check. Set FORGE_TURNSTILE_SECRET and FORGE_TURNSTILE_SITEKEY", |
| 318 | 318 | ); |
| 319 | 319 | const bob = await login("bob"); |
| 320 | expect((await get("/settings/secrets", bob)).status).toBe(404); | |
| 320 | expect((await get("/settings/secrets", bob)).status).toBe(403); | |
| 321 | 321 | expect(await (await get("/acme", bob)).text()).not.toContain("Seal"); |
| 322 | 322 | |
| 323 | 323 | const set = await fetch(`${servers.url}/settings/secrets`, { |
test/security-controls.test.ts +1 −1
| @@ -44,7 +44,7 @@ test("an admin sees every control; with no report each says no check recorded", | ||
| 44 | 44 | |
| 45 | 45 | test("the page is an admin's", async () => { |
| 46 | 46 | expect((await get()).status).toBe(303); |
| 47 | expect((await get(other)).status).toBe(404); | |
| 47 | expect((await get(other)).status).toBe(403); | |
| 48 | 48 | }); |
| 49 | 49 | |
| 50 | 50 | test("a recorded check shows its time; a failed one says failed; junk is ignored", async () => { |
test/settings-backups-buttons.test.ts +1 −1
| @@ -109,7 +109,7 @@ test("Test with nothing typed and nothing sealed says there is nothing to test", | ||
| 109 | 109 | for (const path of ["/settings/backups/test", "/settings/backups/mirror/now"]) { |
| 110 | 110 | test(`${path}: a non-admin, an anonymous visitor and a cross-origin post are refused`, async () => { |
| 111 | 111 | const asBob = await post(path, bob); |
| 112 | expect(asBob.status).toBe(404); | |
| 112 | expect(asBob.status).toBe(403); | |
| 113 | 113 | expect(asBob.headers.getSetCookie().join(";")).not.toMatch(/flash/); |
| 114 | 114 | |
| 115 | 115 | const anon = await post(path, ""); |
test/settings-backups-mirror.test.ts +2 −2
| @@ -89,9 +89,9 @@ describe("Settings → Backups mirror", () => { | ||
| 89 | 89 | |
| 90 | 90 | test("a non-admin and a cross-origin post are refused and change nothing", async () => { |
| 91 | 91 | const asBob = await post("/settings/backups/mirror", GOOD, bob); |
| 92 | expect(asBob.status).toBe(404); | |
| 92 | expect(asBob.status).toBe(403); | |
| 93 | 93 | const asBobClear = await post("/settings/backups/mirror/clear", {}, bob); |
| 94 | expect(asBobClear.status).toBe(404); | |
| 94 | expect(asBobClear.status).toBe(403); | |
| 95 | 95 | const cross = await post("/settings/backups/mirror", GOOD, admin, "https://evil.example"); |
| 96 | 96 | expect(cross.status).toBe(403); |
| 97 | 97 | expect(mirrorSealedSet(app.db)).toEqual([]); |
test/settings-cloud-forms.test.ts +1 −1
| @@ -104,7 +104,7 @@ describe("cloud account forms in Settings", () => { | ||
| 104 | 104 | test("a non-admin and a cross-origin post are refused for each, and the account stays", async () => { |
| 105 | 105 | const before = JSON.stringify(listCloudAccounts(app.db)); |
| 106 | 106 | for (const path of ["rotate", "rename", "remove"]) { |
| 107 | expect((await post(path, member, form(path))).status).toBe(404); | |
| 107 | expect((await post(path, member, form(path))).status).toBe(403); | |
| 108 | 108 | expect((await post(path, admin, form(path), "https://evil.example")).status).toBe(403); |
| 109 | 109 | } |
| 110 | 110 | expect(JSON.stringify(listCloudAccounts(app.db))).toBe(before); |
test/settings-cloud-verify.test.ts +1 −1
| @@ -101,7 +101,7 @@ describe("POST /settings/cloud/verify", () => { | ||
| 101 | 101 | const bob = await login("bob"); |
| 102 | 102 | const alice = await login("alice"); |
| 103 | 103 | const before = calls; |
| 104 | expect((await verify(bob)).status).toBe(404); | |
| 104 | expect((await verify(bob)).status).toBe(403); | |
| 105 | 105 | expect((await verify(alice, "https://evil.example")).status).toBe(403); |
| 106 | 106 | const anon = await verify(""); |
| 107 | 107 | expect(anon.headers.get("location") ?? "").not.toBe("/settings/cloud"); |
test/settings-secret-delete.test.ts +1 −1
| @@ -82,7 +82,7 @@ test("a non-admin, a signed-out caller and a cross-origin post are refused; the | ||
| 82 | 82 | await post(undefined, servers.url), |
| 83 | 83 | await post(admin, "https://evil.example"), |
| 84 | 84 | ]; |
| 85 | expect(refused[0]!.status).toBe(404); | |
| 85 | expect(refused[0]!.status).toBe(403); | |
| 86 | 86 | expect(refused[1]!.headers.get("location")).toStartWith("/signin"); |
| 87 | 87 | expect(refused[2]!.status).toBe(403); |
| 88 | 88 | for (const res of refused) { |
test/shield.test.ts +2 −2
| @@ -67,8 +67,8 @@ afterAll(async () => { | ||
| 67 | 67 | |
| 68 | 68 | test("the page is an admin's: a stranger signs in, anyone else finds nothing, and the API refuses them at the admin gate", async () => { |
| 69 | 69 | expect((await get("/settings/security")).status).toBe(303); |
| 70 | expect((await get("/settings/security", { cookie: bobCookie })).status).toBe(404); | |
| 71 | expect((await post("signin", { cookie: bobCookie })).status).toBe(404); | |
| 70 | expect((await get("/settings/security", { cookie: bobCookie })).status).toBe(403); | |
| 71 | expect((await post("signin", { cookie: bobCookie })).status).toBe(403); | |
| 72 | 72 | // The same answer the capacity door gives this token: never a change, never a hint of one. |
| 73 | 73 | const capacity = await fetch(`${servers.url}/api/v1/admin/capacity/crew.concurrency`, { |
| 74 | 74 | method: "PUT", |
test/signins.test.ts +3 −3
| @@ -214,14 +214,14 @@ describe("sign-ins: tokens and browsers, seen and ended", () => { | ||
| 214 | 214 | expect( |
| 215 | 215 | (await fetch(`${base}/settings/accounts/bo`, { headers: { cookie: adaCookie } })) |
| 216 | 216 | .status, |
| 217 | ).toBe(404); | |
| 217 | ).toBe(403); | |
| 218 | 218 | expect( |
| 219 | 219 | (await post(`/settings/accounts/bo/tokens/${victimToken.id}/delete`, adaCookie)) |
| 220 | 220 | .status, |
| 221 | ).toBe(404); | |
| 221 | ).toBe(403); | |
| 222 | 222 | expect( |
| 223 | 223 | (await post(`/settings/accounts/bo/sessions/${id}/delete`, adaCookie)).status, |
| 224 | ).toBe(404); | |
| 224 | ).toBe(403); | |
| 225 | 225 | // Strangers too. |
| 226 | 226 | expect( |
| 227 | 227 | (await fetch(`${base}/api/v1/users/bo/signins`)).status, |
test/snapshot-mirror-page.test.ts +1 −1
| @@ -95,7 +95,7 @@ describe("the mirror's admin routes", () => { | ||
| 95 | 95 | const guest = await post(path, full); |
| 96 | 96 | expect(guest.status).toBe(303); |
| 97 | 97 | expect(guest.headers.get("location")).toContain("/signin"); |
| 98 | expect((await post(path, full, bob)).status).toBe(404); | |
| 98 | expect((await post(path, full, bob)).status).toBe(403); | |
| 99 | 99 | } |
| 100 | 100 | expect(sealed()).toEqual([]); |
| 101 | 101 | }); |
test/spend-language.test.ts +2 −2
| @@ -39,7 +39,7 @@ test("the spend view is one settings-block under an h2, no card", async () => { | ||
| 39 | 39 | expect(m).toContain('id="spend" data-live="run.queued,run.started,run.finished,usage.*"'); |
| 40 | 40 | }); |
| 41 | 41 | |
| 42 | test("a non-admin gets the one 404", async () => { | |
| 42 | test("a non-admin gets the one 403", async () => { | |
| 43 | 43 | const r = await fetch(`${servers.url}/settings/spend`, { headers: { cookie: plain } }); |
| 44 | expect(r.status).toBe(404); | |
| 44 | expect(r.status).toBe(403); | |
| 45 | 45 | }); |
test/spend-tab.test.ts +2 −2
| @@ -89,12 +89,12 @@ test("a guest is sent to sign in", async () => { | ||
| 89 | 89 | expect(await res.text()).not.toContain("12.34"); |
| 90 | 90 | }); |
| 91 | 91 | |
| 92 | test("a signed-in non-admin, even an app's owner, is told not found and sees no figure", async () => { | |
| 92 | test("a signed-in non-admin, even an app's owner, is refused with 403 and sees no figure", async () => { | |
| 93 | 93 | const res = await fetch(`${servers.url}/settings/spend`, { |
| 94 | 94 | headers: { cookie: await cookieOf("bob") }, |
| 95 | 95 | }); |
| 96 | 96 | const body = await res.text(); |
| 97 | expect(res.status).toBe(404); | |
| 97 | expect(res.status).toBe(403); | |
| 98 | 98 | expect(body).not.toContain("12.34"); |
| 99 | 99 | expect(body).not.toContain("work-plan"); |
| 100 | 100 | expect(body).not.toContain("<h2>Spend</h2>"); |
test/stripe.test.ts +1 −1
| @@ -233,6 +233,6 @@ describe("connecting stripe", () => { | ||
| 233 | 233 | const saved = await form("secret", { secret: "whsec_another12345" }); |
| 234 | 234 | expect(await flashOf(saved)).toContain("Signing secret saved."); |
| 235 | 235 | const asBob = await fetch(`${servers.url}/settings/billing/stripe/connect`, { method: "POST", redirect: "manual", headers: { authorization: `Bearer ${createToken(app.db, bob.id, "bob", "admin").token}`, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ key: KEY }) }); |
| 236 | expect(asBob.status).toBe(404); | |
| 236 | expect(asBob.status).toBe(403); | |
| 237 | 237 | }); |
| 238 | 238 | }); |
test/studio-shipping.test.ts +1 −1
| @@ -1064,7 +1064,7 @@ describe("the studio's crew panel on a forge with no agents yet", () => { | ||
| 1064 | 1064 | expect(settings).not.toContain('href="/settings/crew"'); |
| 1065 | 1065 | |
| 1066 | 1066 | const refused = await get("/settings/crew", teamAdminCookie); |
| 1067 | expect(refused.status).toBe(404); | |
| 1067 | expect(refused.status).toBe(403); | |
| 1068 | 1068 | }); |
| 1069 | 1069 | |
| 1070 | 1070 | test("the app page's way to add a crew member leads unbroken to the door an agent is made through, with nothing dev-only in the way", async () => { |
test/subscription-budget.test.ts +1 −1
| @@ -165,7 +165,7 @@ describe("an account's whole-life budget", () => { | ||
| 165 | 165 | redirect: "manual", |
| 166 | 166 | }, |
| 167 | 167 | ); |
| 168 | expect(refused.status).toBe(404); | |
| 168 | expect(refused.status).toBe(403); | |
| 169 | 169 | expect(budgetsOf(app.db).get(starter)!.budgetUsd).toBe(1); |
| 170 | 170 | const admin = await signIn("alice"); |
| 171 | 171 | const set = await fetch( |
test/subscriptions.test.ts +3 −3
| @@ -666,14 +666,14 @@ describe("the pages", () => { | ||
| 666 | 666 | const page = await fetch(`${base}/settings/subscriptions`, { |
| 667 | 667 | headers: { cookie: guest }, |
| 668 | 668 | }); |
| 669 | expect(page.status).toBe(404); | |
| 669 | expect(page.status).toBe(403); | |
| 670 | 670 | const posted = await fetch(`${base}/settings/subscriptions`, { |
| 671 | 671 | method: "POST", |
| 672 | 672 | headers: { cookie: guest, origin: base }, |
| 673 | 673 | body: new URLSearchParams({ name: "mine", token: OAT }), |
| 674 | 674 | redirect: "manual", |
| 675 | 675 | }); |
| 676 | expect(posted.status).toBe(404); | |
| 676 | expect(posted.status).toBe(403); | |
| 677 | 677 | const work = account(); |
| 678 | 678 | expect(listSubscriptions(app.db)).toHaveLength(1); |
| 679 | 679 | setHeadroom(app.db, work.id, 25); |
| @@ -683,7 +683,7 @@ describe("the pages", () => { | ||
| 683 | 683 | body: new URLSearchParams({ headroom: "0" }), |
| 684 | 684 | redirect: "manual", |
| 685 | 685 | }); |
| 686 | expect(headroom.status).toBe(404); | |
| 686 | expect(headroom.status).toBe(403); | |
| 687 | 687 | expect(getHeadroom(app.db, work.id)).toBe(25); |
| 688 | 688 | }); |
| 689 | 689 | }); |
test/system-memory.test.ts +3 −3
| @@ -99,8 +99,8 @@ describe("GET /system", () => { | ||
| 99 | 99 | expect(body).toContain("Heap max"); |
| 100 | 100 | }); |
| 101 | 101 | |
| 102 | test("a non-admin gets 404 and an anonymous visitor is sent to sign in", async () => { | |
| 103 | expect((await get("bob")).status).toBe(404); | |
| 102 | test("a non-admin gets 403 and an anonymous visitor is sent to sign in", async () => { | |
| 103 | expect((await get("bob")).status).toBe(403); | |
| 104 | 104 | const anon = await fetch(servers.url + "/system", { redirect: "manual" }); |
| 105 | 105 | expect(anon.status).toBe(303); |
| 106 | 106 | }); |
| @@ -144,7 +144,7 @@ describe("reaching /system", () => { | ||
| 144 | 144 | |
| 145 | 145 | test("a refused visitor gets a page, not bare text", async () => { |
| 146 | 146 | const res = await get("bob"); |
| 147 | expect(res.status).toBe(404); | |
| 147 | expect(res.status).toBe(403); | |
| 148 | 148 | expect(await res.text()).toContain("<h1>"); |
| 149 | 149 | }); |
| 150 | 150 | }); |
test/system-stream.test.ts +1 −1
| @@ -101,7 +101,7 @@ describe("/system over server-sent events", () => { | ||
| 101 | 101 | const bob = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 102 | 102 | expect(bob).not.toBe(""); |
| 103 | 103 | const stream = await fetch(`${servers.url}/system/stream`, { headers: { cookie: bob } }); |
| 104 | expect(stream.status).toBe(404); | |
| 104 | expect(stream.status).toBe(403); | |
| 105 | 105 | expect(stream.headers.get("content-type") ?? "").not.toContain("text/event-stream"); |
| 106 | 106 | await stream.body?.cancel(); |
| 107 | 107 | }); |
test/system-telemetry.test.ts +1 −1
| @@ -227,7 +227,7 @@ describe("telemetry:read: the owner grants it, an agent reads with it, nobody el | ||
| 227 | 227 | expect((await tool(tokens["grok"]!, "system")).error).toBe(true); |
| 228 | 228 | expect((await tool(tokens["grok"]!, "flow")).error).toBe(true); |
| 229 | 229 | const bobCookie = await login("bob"); |
| 230 | expect((await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } })).status).toBe(404); | |
| 230 | expect((await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } })).status).toBe(403); | |
| 231 | 231 | const bobPage = await fetch(`${servers.url}/system`, { headers: { cookie: bobCookie } }); |
| 232 | 232 | expect(await bobPage.text()).not.toContain("Telemetry readers"); |
| 233 | 233 | }); |
test/system-timing.test.ts +3 −4
| @@ -41,7 +41,7 @@ test("GET /system names its phases", async () => { | ||
| 41 | 41 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 42 | 42 | }); |
| 43 | 43 | |
| 44 | test("a non-owner gets the 404 page and no sample or memory phase", async () => { | |
| 44 | test("a non-admin is refused at the admin gate, before any reader, sample or memory phase", async () => { | |
| 45 | 45 | const r = await createUser(app.db, { |
| 46 | 46 | origin: "everyday", |
| 47 | 47 | username: "bob", |
| @@ -51,8 +51,7 @@ test("a non-owner gets the 404 page and no sample or memory phase", async () => | ||
| 51 | 51 | if (!r.ok) throw new Error(r.error); |
| 52 | 52 | const bob = createToken(app.db, r.value.id, "t", "admin").token; |
| 53 | 53 | const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${bob}` } }); |
| 54 | expect(res.status).toBe(404); | |
| 54 | expect(res.status).toBe(403); | |
| 55 | 55 | const names = (res.headers.get("server-timing") ?? "").split(", ").map((p) => p.split(";")[0]); |
| 56 | expect(names).toContain("reader"); | |
| 57 | for (const name of ["sample", "caps", "memory", "render"]) expect(names).not.toContain(name); | |
| 56 | for (const name of ["reader", "sample", "caps", "memory", "render"]) expect(names).not.toContain(name); | |
| 58 | 57 | }); |
test/talk-captain.test.ts +2 −2
| @@ -206,7 +206,7 @@ describe("Settings › Captain", () => { | ||
| 206 | 206 | expect(text).toContain("Memory"); |
| 207 | 207 | expect(text).toContain("Morning paper"); |
| 208 | 208 | expect(text).toContain(STARTING_RULES[0]!); |
| 209 | expect((await fetch(`${servers.url}/settings/captain`, { headers: { cookie: cookie(other) } })).status).toBe(404); | |
| 209 | expect((await fetch(`${servers.url}/settings/captain`, { headers: { cookie: cookie(other) } })).status).toBe(403); | |
| 210 | 210 | expect((await fetch(`${servers.url}/settings/captain`, { redirect: "manual" })).status).not.toBe(200); |
| 211 | 211 | |
| 212 | 212 | expect((await post(owner, { do: "add", text: "Lend credit only to friends." })).status).toBe(303); |
| @@ -220,7 +220,7 @@ describe("Settings › Captain", () => { | ||
| 220 | 220 | expect((await post(owner, { do: "routine", kind: "checkin", enabled: "1", every: "30", hour: "" })).status).toBe(303); |
| 221 | 221 | expect(listRoutines(app.db, owner.id).find((r) => r.kind === "checkin")?.everyMin).toBe(30); |
| 222 | 222 | expect((await post(owner, { do: "routine", kind: "checkin", enabled: "1", every: "3", hour: "" })).status).toBe(422); |
| 223 | expect((await post(other, { do: "add", text: "mine" })).status).toBe(404); | |
| 223 | expect((await post(other, { do: "add", text: "mine" })).status).toBe(403); | |
| 224 | 224 | }); |
| 225 | 225 | |
| 226 | 226 | test("a post from another origin changes nothing", async () => { |
test/teams-language.test.ts +4 −4
| @@ -88,11 +88,11 @@ test("one POST saves; Recent changes is then a plain h2 and ul, and the total is | ||
| 88 | 88 | expect(main).toMatch(/id="teams-total">\d+%</); |
| 89 | 89 | }); |
| 90 | 90 | |
| 91 | test("a non-admin still gets 404, on page and post", async () => { | |
| 91 | test("a non-admin is refused with 403, on page and post", async () => { | |
| 92 | 92 | const res = await fetch(`${servers.url}/settings/teams`, { headers: { cookie: other }, redirect: "manual" }); |
| 93 | expect(res.status).toBe(404); | |
| 93 | expect(res.status).toBe(403); | |
| 94 | 94 | const own = await fetch(`${servers.url}/settings/teams/design`, { headers: { cookie: other }, redirect: "manual" }); |
| 95 | expect(own.status).toBe(404); | |
| 95 | expect(own.status).toBe(403); | |
| 96 | 96 | const post = await fetch(`${servers.url}/settings/teams/x`, { method: "POST", headers: { cookie: other }, redirect: "manual" }); |
| 97 | expect(post.status).toBe(404); | |
| 97 | expect(post.status).toBe(403); | |
| 98 | 98 | }); |
test/traffic-history.test.ts +1 −1
| @@ -187,7 +187,7 @@ test("the page offers the three windows, for an admin alone, and a loopback requ | ||
| 187 | 187 | expect(text).not.toContain("<table class=\"table\" data-dense>\n <caption class=\"sr-only\">\n User agent"); |
| 188 | 188 | expect(windowOf("nonsense")).toBe("15m"); |
| 189 | 189 | const notAdmin = `forge_session=${createSession(app.db, bob.id).token}`; |
| 190 | expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(404); | |
| 190 | expect((await fetch(`${servers.url}/settings/traffic?window=7d`, { headers: { cookie: notAdmin } })).status).toBe(403); | |
| 191 | 191 | }); |
| 192 | 192 | |
| 193 | 193 | test("an empty window is one sentence, not a card", async () => { |
test/traffic-sources.test.ts +4 −3
| @@ -257,12 +257,13 @@ describe("review: caps, cache, gates and the terminal door", () => { | ||
| 257 | 257 | expect(readVisits(app.db, scope, since)).toHaveLength(2); |
| 258 | 258 | }); |
| 259 | 259 | |
| 260 | test("a non-admin gets 404 on both pages and the probe; the pages carry the security floor", async () => { | |
| 260 | test("a non-admin is refused both pages and the probe; the pages carry the security floor", async () => { | |
| 261 | 261 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "guest1", email: "[email protected]", password: "correct-horse-battery", isAdmin: false }); |
| 262 | 262 | if (!made.ok) throw new Error(made.error); |
| 263 | 263 | const plain = `forge_session=${createSession(app.db, made.value.id).token}`; |
| 264 | for (const path of ["/settings/traffic/sources", "/ada/none/settings/app/traffic"]) | |
| 265 | expect((await fetch(`${servers.url}${path}`, { headers: { cookie: plain } })).status).toBe(404); | |
| 264 | // The forge's page is an admin route, refused at the gate; an app's is not found. | |
| 265 | for (const [path, status] of [["/settings/traffic/sources", 403], ["/ada/none/settings/app/traffic", 404]] as const) | |
| 266 | expect((await fetch(`${servers.url}${path}`, { headers: { cookie: plain } })).status).toBe(status); | |
| 266 | 267 | expect((await fetch(`${servers.url}/api/v1/admin/traffic/probe?scope=forge`, { headers: { cookie: plain } })).status).not.toBe(200); |
| 267 | 268 | const ok = await fetch(`${servers.url}/settings/traffic/sources`, { headers: { cookie } }); |
| 268 | 269 | expect(ok.status).toBe(200); |
test/traffic-truth.test.ts +1 −1
| @@ -339,7 +339,7 @@ test("a stranger's deep read reaches the record with its class, and the board an | ||
| 339 | 339 | expect(text).not.toContain("203.0.113.200"); |
| 340 | 340 | expect(board.headers.get("cache-control") ?? "").not.toContain("public"); |
| 341 | 341 | |
| 342 | expect((await get("/settings/traffic", { cookie: notAdmin })).status).toBe(404); | |
| 342 | expect((await get("/settings/traffic", { cookie: notAdmin })).status).toBe(403); | |
| 343 | 343 | expect((await get("/settings/traffic")).status).toBeGreaterThanOrEqual(300); |
| 344 | 344 | expect((await get("/api/v1/admin/kpis/probe")).status).toBe(404); |
| 345 | 345 | expect((await get("/api/v1/admin/kpis/probe", { authorization: `Bearer ${await mintToken("bob", "read")}` })).status).toBe(404); |
test/trusted-fold.test.ts +1 −1
| @@ -252,7 +252,7 @@ describe("the doors", () => { | ||
| 252 | 252 | ] as const) { |
| 253 | 253 | const res = await post(path, bob.value, body); |
| 254 | 254 | await res.arrayBuffer(); |
| 255 | expect(res.status).toBe(404); | |
| 255 | expect(res.status).toBe(403); | |
| 256 | 256 | } |
| 257 | 257 | const { listTrusted, foldInStopped } = await import("../src/core/trusted_sources.ts"); |
| 258 | 258 | expect(listTrusted(f.app.db).map((s) => s.account)).toEqual(["jmoran"]); |
test/tui-teams.test.ts +1 −1
| @@ -122,7 +122,7 @@ describe("teams in the terminal", () => { | ||
| 122 | 122 | const bob = getUserByName(forge.db, "bob")!; |
| 123 | 123 | const cookie = (id: string) => `forge_session=${createSession(forge.db, id).token}`; |
| 124 | 124 | const denied = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(bob.id) } }); |
| 125 | expect(denied.status).toBe(404); | |
| 125 | expect(denied.status).toBe(403); | |
| 126 | 126 | await denied.body?.cancel(); |
| 127 | 127 | const res = await fetch(`${servers.url}/settings/teams/stream`, { headers: { cookie: cookie(alice.id) } }); |
| 128 | 128 | expect(res.headers.get("content-type")).toContain("text/event-stream"); |
test/usage-report.test.ts +1 −1
| @@ -111,5 +111,5 @@ test("Usage is one page: the four tabs on every tab, Spend admin-only, one sideb | ||
| 111 | 111 | expect(text.match(/data-nav/g) ?? []).not.toHaveLength(0); |
| 112 | 112 | expect(text).not.toMatch(/data-nav[^>]*>[^<]*(<svg[\s\S]*?<\/svg>)?\s*(Capacity|Telemetry)\s*</); |
| 113 | 113 | } |
| 114 | expect((await get("/settings/spend", "bob")).status).toBe(404); | |
| 114 | expect((await get("/settings/spend", "bob")).status).toBe(403); | |
| 115 | 115 | }); |
test/usage-timing.test.ts +1 −2
| @@ -53,7 +53,7 @@ test("GET /settings/usage names its phases", async () => { | ||
| 53 | 53 | expect(parts.find((p) => p.startsWith(`${name};`))).toMatch(/^[a-z]+;dur=\d+\.\d$/); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | test("a non-admin's 403 names session but not usage or render", async () => { | |
| 56 | test("a non-admin is refused by the admin gate before the page runs: no usage or render phase", async () => { | |
| 57 | 57 | const bob = await createUser(app.db, { |
| 58 | 58 | origin: "seed", |
| 59 | 59 | trusted: true, |
| @@ -74,7 +74,6 @@ test("a non-admin's 403 names session but not usage or render", async () => { | ||
| 74 | 74 | await res.text(); |
| 75 | 75 | expect(res.status).toBe(403); |
| 76 | 76 | const timing = res.headers.get("server-timing") ?? ""; |
| 77 | expect(timing).toContain("session;"); | |
| 78 | 77 | for (const name of ["patterns", "usage", "render"]) expect(timing).not.toContain(`${name};`); |
| 79 | 78 | }); |
| 80 | 79 |
test/web-apps.test.ts +2 −2
| @@ -749,7 +749,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 749 | 749 | // ---- the crew ---- |
| 750 | 750 | |
| 751 | 751 | test("the crew section: admin only, and an agent made on the web", async () => { |
| 752 | expect((await web(bob, "/settings/crew")).status).toBe(404); | |
| 752 | expect((await web(bob, "/settings/crew")).status).toBe(403); | |
| 753 | 753 | expect(await bodyOf(bob, "/settings")).not.toContain( |
| 754 | 754 | 'href="/settings/crew"', |
| 755 | 755 | ); |
| @@ -817,7 +817,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 817 | 817 | const forged = await web(bob, "/settings/crew/bot", { |
| 818 | 818 | form: { model: "opus" }, |
| 819 | 819 | }); |
| 820 | expect(forged.status).toBe(404); | |
| 820 | expect(forged.status).toBe(403); | |
| 821 | 821 | expect(getAgentByName(app.db, "bot")?.model).toBe("sonnet"); |
| 822 | 822 | }); |
| 823 | 823 |
new above
offline