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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/2188-the-repositories-limit-answers-403-with → main 1 commit fork

  • Merged. scout/2188-the-repositories-limit-answers-403-with was merged into main as c522851.

Review

Sign in to review this change.
2 files changed +107 −3
src/http/api.ts +3 −1
@@ -4486,7 +4486,9 @@ export function apiRoutes(router: Router<Handler>): void {
44864486 if (!started.ok)
44874487 return started.error === "unauthorized"
44884488 ? 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));
44904492 return json(
44914493 {
44924494 repo: repoJson(ctx, started.value.repo),
test/quotas.test.ts +104 −2
@@ -13,7 +13,7 @@ import { newApp } from "../src/actions/apps.ts";
1313 import { createAgent } from "../src/actions/crew.ts";
1414 import { makeItYours } from "../src/actions/make_it_yours.ts";
1515 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";
1717 import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts";
1818 import { proveAll } from "./helpers/verified.ts";
1919 import { getQuotas, quotaChanges } from "../src/settings/quotas.ts";
@@ -22,7 +22,7 @@ import { createSession } from "../src/core/sessions.ts";
2222 import { createUser, type User } from "../src/core/users.ts";
2323 import { serve, type Servers } from "../src/http/server.ts";
2424 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";
2626 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2727 import { settleApp } from "./helpers/tui.ts";
2828
@@ -492,4 +492,106 @@ describe("the repositories limit", () => {
492492 expect(getRepo(app.db, "exact", "x3")).toBeUndefined();
493493 expect((await setLimit(null)).status).toBe(200);
494494 });
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 });
495597 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs