Public: anyone can read this; the crew can change it.
The repositories limit answers 403 with the same sentence at every door, migrate included, and each door's refusal has a test #2190
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2188-the-repositories-limit-answers-403-with → main 1 commit fork
2 files
changed +107
−3
src/http/api.ts +3 −1
| @@ -4486,7 +4486,9 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 4486 | 4486 | if (!started.ok) |
| 4487 | 4487 | return started.error === "unauthorized" |
| 4488 | 4488 | ? respondAccess(started.error, 401) |
| 4489 | : apiError(started.error, 422, migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!))); | |
| 4489 | : started.error === "repository_limit" | |
| 4490 | ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!)) | |
| 4491 | : apiError(started.error, 422, migrateMessage(started.error)); | |
| 4490 | 4492 | return json( |
| 4491 | 4493 | { |
| 4492 | 4494 | repo: repoJson(ctx, started.value.repo), |
test/quotas.test.ts +104 −2
| @@ -13,7 +13,7 @@ import { newApp } from "../src/actions/apps.ts"; | ||
| 13 | 13 | import { createAgent } from "../src/actions/crew.ts"; |
| 14 | 14 | import { makeItYours } from "../src/actions/make_it_yours.ts"; |
| 15 | 15 | import { createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 16 | import { getRepo } from "../src/core/repos.ts"; | |
| 16 | import { getRepo, listReposOwnedBy } from "../src/core/repos.ts"; | |
| 17 | 17 | import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts"; |
| 18 | 18 | import { proveAll } from "./helpers/verified.ts"; |
| 19 | 19 | import { getQuotas, quotaChanges } from "../src/settings/quotas.ts"; |
| @@ -22,7 +22,7 @@ import { createSession } from "../src/core/sessions.ts"; | ||
| 22 | 22 | import { createUser, type User } from "../src/core/users.ts"; |
| 23 | 23 | import { serve, type Servers } from "../src/http/server.ts"; |
| 24 | 24 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 25 | import { newRepoScreen } from "../src/tui/screens/newrepo.ts"; | |
| 25 | import { forkScreen, newRepoScreen } from "../src/tui/screens/newrepo.ts"; | |
| 26 | 26 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 27 | 27 | import { settleApp } from "./helpers/tui.ts"; |
| 28 | 28 | |
| @@ -492,4 +492,106 @@ describe("the repositories limit", () => { | ||
| 492 | 492 | expect(getRepo(app.db, "exact", "x3")).toBeUndefined(); |
| 493 | 493 | expect((await setLimit(null)).status).toBe(200); |
| 494 | 494 | }); |
| 495 | test("every door refuses at exactly the limit with its words, and makes nothing", async () => { | |
| 496 | const made = await createUser(app.db, { | |
| 497 | trusted: true, | |
| 498 | username: "doors", | |
| 499 | email: "[email protected]", | |
| 500 | password: "a-good-passphrase", | |
| 501 | }); | |
| 502 | if (!made.ok) throw new Error(made.error); | |
| 503 | const doors = made.value; | |
| 504 | const source = await newApp(app, boss, { name: "doorsrc", visibility: "public" }); | |
| 505 | if (!source.ok) throw new Error(String(source.error)); | |
| 506 | expect((await setLimit(2)).status).toBe(200); | |
| 507 | for (const name of ["d1", "d2"]) expect((await createRepo(app, doors, { name })).ok).toBe(true); | |
| 508 | const owned = () => listReposOwnedBy(app.db, doors.id).length; | |
| 509 | const sentence = "2 of 2 repositories"; | |
| 510 | const token = createToken(app.db, doors.id, "t", "rw").token; | |
| 511 | const cookie = `forge_session=${createSession(app.db, doors.id).token}`; | |
| 512 | const form = (path: string, fields: Record<string, string>) => | |
| 513 | fetch(`${servers.url}${path}`, { | |
| 514 | method: "POST", | |
| 515 | headers: { cookie, origin: servers.url }, | |
| 516 | body: new URLSearchParams(fields), | |
| 517 | redirect: "manual", | |
| 518 | }); | |
| 519 | /** The sentence a redirecting form flashed on its way back. */ | |
| 520 | const flashed = (res: Response): string => { | |
| 521 | const raw = /forge_flash=([^;]*)/.exec(res.headers.get("set-cookie") ?? "")?.[1] ?? ""; | |
| 522 | return raw === "" ? "" : (JSON.parse(decodeURIComponent(raw)) as { message: string }).message; | |
| 523 | }; | |
| 524 | const before = owned(); | |
| 525 | expect(before).toBe(2); | |
| 526 | ||
| 527 | // The fork page: a flash on the repository. | |
| 528 | const fork = await form("/boss/doorsrc/fork", { name: "forked" }); | |
| 529 | expect(fork.status).toBe(303); | |
| 530 | expect(flashed(fork)).toBe(`Not made: ${sentence}.`); | |
| 531 | ||
| 532 | // The new-app page and the API. | |
| 533 | const appPage = await form("/apps/new", { owner: "doors", name: "door-app", description: "x" }); | |
| 534 | expect(appPage.status).toBe(422); | |
| 535 | expect(await appPage.text()).toContain(`Not made: ${sentence}.`); | |
| 536 | const appApi = await post(token, "/api/v1/apps", { name: "door-app" }); | |
| 537 | expect(appApi.status).toBe(403); | |
| 538 | expect(await appApi.json()).toMatchObject({ error: "repository_limit", message: sentence }); | |
| 539 | ||
| 540 | // The migrate page and the API (a literal address: no lookup). | |
| 541 | const url = "https://93.184.216.34/someone/gears.git"; | |
| 542 | const migratePage = await form("/migrate", { url, owner: "doors", name: "gears" }); | |
| 543 | expect(migratePage.status).toBe(422); | |
| 544 | expect(await migratePage.text()).toContain(`Not made: ${sentence}.`); | |
| 545 | const migrateApi = await post(token, "/api/v1/migrate", { source: { url }, name: "gears" }); | |
| 546 | expect(migrateApi.status).toBe(403); | |
| 547 | expect(await migrateApi.json()).toMatchObject({ error: "repository_limit", message: sentence }); | |
| 548 | // Any other migrate refusal keeps its 422. | |
| 549 | const badUrl = await post(token, "/api/v1/migrate", { source: { url: "file:///etc" } }); | |
| 550 | expect(badUrl.status).toBe(422); | |
| 551 | ||
| 552 | // The studio's make-it-yours form. | |
| 553 | const mine = await form("/boss/doorsrc/make-it-yours", { name: "mine" }); | |
| 554 | expect(mine.status).toBe(303); | |
| 555 | expect(flashed(mine)).toBe(`Not made: ${sentence}.`); | |
| 556 | ||
| 557 | // The MCP make-it-yours tool. | |
| 558 | const rpc = await fetch(`${servers.url}/mcp`, { | |
| 559 | method: "POST", | |
| 560 | headers: { | |
| 561 | "content-type": "application/json", | |
| 562 | accept: "application/json, text/event-stream", | |
| 563 | authorization: `Bearer ${token}`, | |
| 564 | }, | |
| 565 | body: JSON.stringify({ | |
| 566 | jsonrpc: "2.0", | |
| 567 | id: 1, | |
| 568 | method: "tools/call", | |
| 569 | params: { name: "make_it_yours", arguments: { repo: "boss/doorsrc", dryRun: false } }, | |
| 570 | }), | |
| 571 | }); | |
| 572 | const answer = ((await rpc.json()) as any).result; | |
| 573 | expect(answer.isError).toBe(true); | |
| 574 | expect(answer.content[0].text).toContain(sentence); | |
| 575 | ||
| 576 | // The terminal's fork screen. | |
| 577 | const terminal = headlessTerminal(120, 30); | |
| 578 | const { app: tui, session } = createForgeApp( | |
| 579 | app, | |
| 580 | { sshUsername: "doors", remoteAddress: "127.0.0.1", fingerprint: "SHA256:doors", user: doors }, | |
| 581 | { terminal, exitHooks: false }, | |
| 582 | ); | |
| 583 | tui.start(); | |
| 584 | try { | |
| 585 | tui.ctx.push(forkScreen(session, tui.ctx, source.value.repo)); | |
| 586 | await settleApp(tui); | |
| 587 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 588 | await settleApp(tui); | |
| 589 | expect(tui.screen.toText()).toContain(`not made: ${sentence}`); | |
| 590 | } finally { | |
| 591 | tui.stop(); | |
| 592 | } | |
| 593 | ||
| 594 | expect(owned()).toBe(before); | |
| 595 | expect((await setLimit(null)).status).toBe(200); | |
| 596 | }); | |
| 495 | 597 | }); |
new above
offline