Public: anyone can read this; the crew can change it.

A set repository limit refuses one more repository, fork or copy, and the refusal says "10 of 10 repositories" #2130

Merged opened this

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

Merged

scout/forge:scout/2093-a-set-repository-limit-refuses-one-more → main 6 commits fork 8 threads · 8 unresolved

  • Merged. scout/2093-a-set-repository-limit-refuses-one-more was merged into main as 8bde250.

Review

Sign in to review this change.
15 files changed +287 −23
README.md +1 −1
@@ -1126,7 +1126,7 @@ A forge admin sees every declared flag at `/settings/flags` (Settings → Flags)
11261126
11271127 Every limit the owner can hit is declared in `src/settings/limits.ts` with its default, bounds and reason, and changed live by a forge admin at `/admin/limits`, `PUT /api/v1/admin/limits/:key` or the `limits` MCP tool (never by an agent). Each change is kept with who made it.
11281128
1129 Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is not enforced yet (#2093): no create path reads it.
1129 Per-account quotas (`src/settings/quotas.ts`) — how many `repositories` and `apps` one account may own — are set the same way: **Account quotas** on `/admin/limits`, `GET`/`PATCH /api/v1/admin/settings` (`{"quotas": {"repositories": 5, "apps": null}}`) or the `quotas` MCP tool (dry by default, admin scope, never an agent). A value is a positive whole number, or empty/`null` for no limit, which is the default; anything else is a 422. Every change is kept with who made it and listed beside the form. The `apps` limit is enforced (`src/actions/app_limit.ts`): with one set, an account owning that many apps is refused one more, unless the actor is a forge admin; with none set nothing is asked. The check and the insert are separate statements, so two concurrent creates can pass by one. The refusal reads "This account is at its limit: 3 of 3 apps. Remove one to make room." and each door answers it its own way: the API (`POST /api/v1/apps` and `POST /api/v1/repos/:owner/:name/app`) a 403 `{"error": "app_limit", "message", "limit", "count"}`; the new-app page and the app settings form a 422 with the sentence on the form; make-it-yours the same sentence as an invalid error, before the fork, so no repository is left behind. The `repositories` limit is enforced too (`src/actions/repos.ts`): with one set, an account owning that many repositories (sealed forks are not counted) is refused one more by create, fork, make-it-yours, mirror, migrate and new app, before anything is made on disk. Forge admins and agents are exempt, and creating under an organisation is not counted: the quota is per account, so only a repository the actor owns itself is asked. The refusal reads "2 of 2 repositories"; the API answers 403 `{"error": "repository_limit", "message"}`, the pages say "Not made: 2 of 2 repositories." on the form (a 422), and the terminal shows it as a toast. The check and the insert are separate statements here as well, so two concurrent creates can pass by one.
11301130
11311131 ## Choosing among variants
11321132
src/actions/make_it_yours.ts +6 −2
@@ -42,7 +42,7 @@ import { isActive, type User } from "../core/users.ts";
4242 import { err, ok, type Result } from "../kernel/result.ts";
4343 import { requireAppRoom, appLimitWords } from "./app_limit.ts";
4444 import { enableApp } from "./apps.ts";
45 import { FORKS_PER_DAY, forkRepo } from "./repos.ts";
45 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "./repos.ts";
4646
4747 export interface MakeItYoursPlan {
4848 readonly dryRun: boolean;
@@ -69,7 +69,9 @@ export type MakeItYoursError =
6969 | { readonly kind: "own" }
7070 /** Today's copies are made; another may be made from `resetsAt`. */
7171 | { readonly kind: "limit"; readonly resetsAt: string }
72 | { readonly kind: "invalid"; readonly message: string };
72 | { readonly kind: "invalid"; readonly message: string }
73 /** The owner's repositories limit is reached: `message` says "10 of 10 repositories". */
74 | { readonly kind: "repository_limit"; readonly message: string };
7375
7476 export const COPIED = ["the code", "its history"] as const;
7577 export const NOT_COPIED = [
@@ -228,6 +230,8 @@ export async function makeItYours(
228230 });
229231 case "limit":
230232 return err({ kind: "limit", resetsAt: resetsAt(app, actor.id, now) });
233 case "repository_limit":
234 return err({ kind: "repository_limit", message: repositoryLimitMessage(app, actor) });
231235 default:
232236 return err({
233237 kind: "invalid",
src/actions/migrate.ts +3 −1
@@ -170,7 +170,7 @@ export function migrationJobFor(
170170 }
171171
172172 /** A refusal in words a person can act on. */
173 export function migrateMessage(error: MigrateError): string {
173 export function migrateMessage(error: MigrateError, limit = "at the repositories limit"): string {
174174 switch (error) {
175175 case "invalid_url":
176176 return "That is not a URL this forge will clone from. Paste an https:// (or http://, git://) address of a public repository, with no password in it.";
@@ -196,6 +196,8 @@ export function migrateMessage(error: MigrateError): string {
196196 return "Verify your email first: follow the link we sent, or ask for another under Settings.";
197197 case "refused":
198198 return "New accounts cannot create repositories yet.";
199 case "repository_limit":
200 return `Not made: ${limit}.`;
outdated

The issue asks for the refusal to say "10 of 10 repositories". Show the sentence here as the other doors do.

199201 }
200202 }
201203
src/actions/repos.ts +26 −2
@@ -9,6 +9,7 @@ import type { App } from "../app.ts";
99 import { canSeeSealed } from "../core/sealed.ts";
1010 import { capacityValue } from "../core/capacity.ts";
1111 import { NEW_FOR_MS } from "./accounts.ts";
12 import { repositoryLimitSentence } from "../settings/quotas.ts";
1213 import {
1314 createLabel as insertLabel,
1415 deleteLabel as removeLabel,
@@ -69,7 +70,27 @@ export type CreateRepoActionError =
6970 | CreateRepoError
7071 | "unauthorized"
7172 | "forbidden_owner"
72 | "refused";
73 | "refused"
74 | "repository_limit";
75
76 /**
77 * The repositories limit (#2093): when the owner has set one, an account at
78 * it makes no more, by create, fork or copy. Admins and agents are not held
79 * to it. Only a repository the account owns itself is counted and asked: creating
80 * under an organisation is not, since the quota is per account. Returns the refusal sentence ("10 of 10 repositories") or nothing.
81 */
82 export function repositoryLimitRefusal(app: App, user: User, ownerId = user.id): string | undefined {
83 if (user.isAdmin || user.kind === "agent") return undefined;
84 return repositoryLimitSentence(app.db, ownerId);
85 }
86
87 /**
88 * The sentence for a refusal just given. Always words: if a repository went
89 * between the refusal and now, it still says the limit was the reason.
90 */
91 export function repositoryLimitMessage(app: App, user: User, ownerId = user.id): string {
92 return repositoryLimitRefusal(app, user, ownerId) ?? "at the repositories limit";
93 }
7394
7495 /**
7596 * The new-account guard (`guard_new_accounts`): when it is on, an account
@@ -117,6 +138,8 @@ export async function createRepo(
117138 if (!guarded.ok) return guarded;
118139 const ownerId = resolveOwner(app, user.value, input.owner ?? "");
119140 if (ownerId === undefined) return err("forbidden_owner");
outdated

The limit is only asked when the owner is the actor, so creating under an org is never counted. That is defensible, because the quota is per account, but write it down: in the doc comment above repositoryLimitRefusal and in the README.

141 if (ownerId === user.value.id && repositoryLimitRefusal(app, user.value) !== undefined)
142 return err("repository_limit");
120143 const created = await initRepo(app.db, app.reposRoot, {
121144 ownerId,
122145 name: input.name,
@@ -148,13 +171,14 @@ export async function forkRepo(
148171 parent: Repo,
149172 name?: string,
150173 options: { visibility?: "public" | "private"; now?: number } = {},
151 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused">> {
174 ): Promise<Result<Repo, ForkRepoError | "not_found" | "unauthorized" | "unverified" | "refused" | "repository_limit">> {
152175 const user = requireParticipant(app, actor, parent);
153176 if (!user.ok) return user;
154177 const proven = requireVerified(app, user.value);
155178 if (!proven.ok) return proven;
156179 const guarded = requireNewAccountGuard(app, user.value);
157180 if (!guarded.ok) return guarded;
181 if (repositoryLimitRefusal(app, user.value) !== undefined) return err("repository_limit");
158182 const forked = await cloneRepo(
159183 app.db,
160184 app.reposRoot,
src/http/api.ts +11 −2
@@ -133,6 +133,7 @@ import {
133133 deleteWebhook,
134134 FORKS_PER_DAY,
135135 forkRepo,
136 repositoryLimitMessage,
136137 privateRepoForSealed,
137138 updateRepoSettings,
138139 } from "../actions/repos.ts";
@@ -1876,6 +1877,8 @@ export function apiRoutes(router: Router<Handler>): void {
18761877 if (!created.ok) {
18771878 if (created.error === "forbidden_owner")
18781879 return json({ error: "forbidden_owner" }, 403);
1880 if (created.error === "repository_limit")
1881 return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!));
18791882 return respondAccess(created.error, 422);
18801883 }
18811884 return json(created.value, 201);
@@ -1986,7 +1989,9 @@ export function apiRoutes(router: Router<Handler>): void {
19861989 str(input.name) || undefined,
19871990 );
19881991 if (!forked.ok)
1989 return forked.error === "limit"
1992 return forked.error === "repository_limit"
1993 ? apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!))
1994 : forked.error === "limit"
19901995 ? apiError("limit", 429, `at most ${FORKS_PER_DAY} forks a day: try again tomorrow`)
19911996 : respondAccess(forked.error, 422);
19921997 return json(forked.value, 201);
@@ -4242,6 +4247,8 @@ export function apiRoutes(router: Router<Handler>): void {
42424247 },
42434248 429,
42444249 )
4250 : made.error.kind === "repository_limit"
4251 ? apiError("repository_limit", 403, made.error.message)
42454252 : made.error.kind === "invalid"
42464253 ? apiError("invalid", 422, made.error.message)
42474254 : made.error.kind === "own"
@@ -4371,6 +4378,8 @@ export function apiRoutes(router: Router<Handler>): void {
43714378 ...(typeof input.agent === "string" ? { agent: input.agent } : {}),
43724379 });
43734380 if (!made.ok) {
4381 if (made.error === "repository_limit")
4382 return apiError("repository_limit", 403, repositoryLimitMessage(ctx.app, ctx.auth.user!));
43744383 if (made.error === "app_limit")
43754384 return appLimitResponse(
43764385 ctx,
@@ -4445,7 +4454,7 @@ export function apiRoutes(router: Router<Handler>): void {
44454454 if (!started.ok)
44464455 return started.error === "unauthorized"
44474456 ? respondAccess(started.error, 401)
4448 : apiError(started.error, 422, migrateMessage(started.error));
4457 : apiError(started.error, 422, migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!)));

Here repository_limit comes back as a 422 whose message is "Not made: 2 of 2 repositories.". Every other API door answers 403 with the message "2 of 2 repositories", and so does the README. Follow-up: #2173.

44494458 return json(
44504459 {
44514460 repo: repoJson(ctx, started.value.repo),
src/http/mcp.ts +2
@@ -1396,6 +1396,8 @@ const TOOLS: readonly Tool[] = [
13961396 if (!made.ok)
13971397 return made.error.kind === "limit"
13981398 ? bad("limit", `at most ${COPIES_PER_DAY} copies a day; another after ${made.error.resetsAt}`)
1399 : made.error.kind === "repository_limit"
1400 ? bad("repository_limit", made.error.message)
13991401 : made.error.kind === "invalid"
14001402 ? bad("invalid", made.error.message)
14011403 : made.error.kind === "own"
src/origins/mirror.ts +13 −2
@@ -25,6 +25,7 @@ import { repoDir } from "../git/repos.ts";
2525 import { err, ok, type Result } from "../kernel/result.ts";
2626 import { bustOrigin } from "../view/cache.ts";
2727 import { providerForOrigin } from "./credential.ts";
28 import { repositoryLimitMessage, repositoryLimitRefusal } from "../actions/repos.ts";
2829 import { redact, safeRemoteUrl } from "./credentials.ts";
2930 import { adoptDefaultBranch, configureMirror } from "./git-mirror.ts";
3031 import { advance, createJob, runningJob, type MirrorJob } from "./jobs.ts";
@@ -37,7 +38,8 @@ export type MirrorError =
3738 | "name_taken"
3839 | "already_mirrored"
3940 | "git_init_failed"
40 | "unreachable";
41 | "unreachable"
42 | "repository_limit";
4143
4244 export interface MirrorResult {
4345 readonly repo: Repo;
@@ -119,6 +121,10 @@ export async function mirrorRepo(
119121 const chosen = availableName(app, actor.id, wanted, origin);
120122 if (chosen === undefined) return err("name_taken");
121123
124 // A mirror is a repository the account owns: the repositories limit asks
125 // here as it does at create and fork (#2093).
126 if (repositoryLimitRefusal(app, actor) !== undefined) return err("repository_limit");
127
122128 const created = await createRepo(app.db, app.reposRoot, {
123129 ownerId: actor.id,
124130 name: chosen,
@@ -228,7 +234,10 @@ export function startMirror(
228234 advance(job.id, {
229235 stage: "failed",
230236 detail: "",
231 error: mirrorMessage(result.error),
237 error:
238 result.error === "repository_limit"
239 ? `Not mirrored: ${repositoryLimitMessage(app, actor)}.`
240 : mirrorMessage(result.error),
232241 });
233242 return;
234243 }
@@ -284,6 +293,8 @@ export function mirrorMessage(error: MirrorError): string {
284293 return "the local repository could not be created";
285294 case "unreachable":
286295 return "the origin could not be reached";
296 case "repository_limit":
297 return "this account is at the forge's repositories limit";
287298 }
288299 }
289300
src/settings/quotas.ts +21 −2
@@ -3,8 +3,8 @@
33 * `forge_setting` under `quota.<key>`, and every change is kept in
44 * `forge_setting_change` (who, when, from what to what). The default for
55 * each is no limit (`null`), and creating an app asks the apps limit
6 * (`actions/app_limit.ts`); repositories are not enforced yet. Room for more
7 * keys is the `QUOTA_KEYS` list.
6 * (`actions/app_limit.ts`); creating, forking and copying a repository ask the

This header now says the limit is enforced, but README.md:1129 still says "not enforced yet: no create path reads it". Update the README paragraph in this pull so the two agree.

7 * repositories limit first (`actions/repos.ts`). Room for more keys is the `QUOTA_KEYS` list.
88 */
99
1010 import type { Database } from "bun:sqlite";
@@ -49,6 +49,25 @@ export function getQuotas(db: Database): Quotas {
4949 return { repositories: getQuota(db, "repositories"), apps: getQuota(db, "apps") };
5050 }
5151
52 /** Repositories an account owns, a sealed fork (nobody's to count) aside. */
53 export function repositoriesOwned(db: Database, ownerId: string): number {
54 return (
55 db
56 .query<{ n: number }, [string]>(
57 "SELECT count(*) AS n FROM repo WHERE owner_id = ?1 AND sealed_for IS NULL",
58 )
59 .get(ownerId)?.n ?? 0
60 );
61 }
62
63 /** The sentence a refused creation says, or nothing when there is room. */
64 export function repositoryLimitSentence(db: Database, ownerId: string): string | undefined {
65 const limit = getQuota(db, "repositories");
66 if (limit === null) return undefined;
67 const owned = repositoriesOwned(db, ownerId);
68 return owned >= limit ? `${owned} of ${limit} repositories` : undefined;
69 }
70
5271 /** Set `key` (`null` clears it) and keep the change. */
5372 export function setQuota(
5473 db: Database,
src/tui/screens/newrepo.ts +9 −3
@@ -3,7 +3,7 @@
33 * because the next thing anyone does is look at it.
44 */
55
6 import { createRepo, forkRepo } from "../../actions/repos.ts";
6 import { createRepo, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
77 import { listOrgsForUser } from "../../core/orgs.ts";
88 import type { Repo } from "../../core/repos.ts";
99 import type { Ctx, Screen } from "../app/app.ts";
@@ -42,7 +42,10 @@ export function newRepoScreen(f: Session, ctx: Ctx, owner?: string): Screen {
4242 visibility,
4343 });
4444 if (!created.ok) {
45 error = errorText(created.error);
45 error =
46 created.error === "repository_limit"
47 ? `not made: ${repositoryLimitMessage(f.app, f.user!)}`
48 : errorText(created.error);
4649 ctx.toast(error, "danger");
4750 return;
4851 }
@@ -154,7 +157,10 @@ export function forkScreen(f: Session, ctx: Ctx, parent: Repo): Screen {
154157 runAsync(ctx, async () => {
155158 const forked = await forkRepo(f.app, f.user, parent, name);
156159 if (!forked.ok) {
157 error = errorText(forked.error);
160 error =
161 forked.error === "repository_limit"
162 ? `not made: ${repositoryLimitMessage(f.app, f.user!)}`
163 : errorText(forked.error);
158164 ctx.toast(error, "danger");
159165 return;
160166 }
src/web/pages/apps.ts +4 −1
@@ -12,6 +12,7 @@
1212 * 404 for everyone.
1313 */
1414
15 import { repositoryLimitMessage } from "../../actions/repos.ts";
1516 import { appLimitWords } from "../../actions/app_limit.ts";
1617 import { getUserByName } from "../../core/users.ts";
1718 import {
@@ -419,7 +420,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
419420 body: newAppForm(
420421 ctx,
421422 values,
422 made.error === "app_limit" && ownerId !== undefined
423 made.error === "repository_limit"
424 ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user!)}.`
425 : made.error === "app_limit" && ownerId !== undefined
423426 ? appLimitWords(ctx.app, ownerId)
424427 : (NEW_APP_ERRORS[made.error] ?? "That app could not be created."),
425428 ),
src/web/pages/migrate.ts +4 −1
@@ -7,6 +7,7 @@
77 * Every mutation is `migrateRepo`; the page only puts its answer into words.
88 */
99
10 import { repositoryLimitMessage } from "../../actions/repos.ts";
1011 import { listAllAgents } from "../../actions/crew.ts";
1112 import {
1213 migrateMessage,
@@ -354,7 +355,9 @@ export async function migrateSubmit(ctx: Ctx): Promise<Response> {
354355 if (!started.ok) {
355356 if (started.error === "forbidden_owner" || started.error === "unauthorized")
356357 return new Response("forbidden", { status: 403 });
357 return again(migrateMessage(started.error));
358 return again(
359 migrateMessage(started.error, repositoryLimitMessage(ctx.app, ctx.auth.user!)),
360 );
358361 }
359362 return redirect(`/migrate/jobs/${started.value.job.id}`);
360363 }
src/web/pages/repo.ts +3 −1
@@ -9,7 +9,7 @@
99 */
1010
1111 import { canRead, listForks, type Repo } from "../../core/repos.ts";
12 import { FORKS_PER_DAY, forkRepo } from "../../actions/repos.ts";
12 import { FORKS_PER_DAY, forkRepo, repositoryLimitMessage } from "../../actions/repos.ts";
1313 import { forkDrift, takeUpdates, type ForkDrift } from "../../actions/forks.ts";
1414 import { lineageOf } from "../../core/following.ts";
1515 import {
@@ -195,6 +195,8 @@ export async function forkSubmit(ctx: Ctx): Promise<Response> {
195195 const reason =
196196 forked.error === "name_taken"
197197 ? "You already have a repository by that name."
198 : forked.error === "repository_limit"
199 ? `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`
198200 : forked.error === "limit"
199201 ? `That is today's forks made (${FORKS_PER_DAY} a day). Try again tomorrow.`
200202 : "That fork could not be created.";
src/web/pages/site.ts +2 −1
@@ -13,7 +13,7 @@ import { signOutHere } from "../../actions/signins.ts";
1313 import { clientAddress } from "../../http/peer.ts";
1414 import { verifyEmail } from "../../actions/verify.ts";
1515 import { STRANGER_MAX } from "../../http/limits.ts";
16 import { createRepo } from "../../actions/repos.ts";
16 import { createRepo, repositoryLimitMessage } from "../../actions/repos.ts";
1717 import { HUMAN_CHECK_MESSAGE, PASSKEY_REQUIRED_MESSAGE, signUp } from "../../actions/users.ts";
1818 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
1919 import { listPins } from "../../core/pins.ts";
@@ -689,6 +689,7 @@ export async function newRepoSubmit(ctx: Ctx): Promise<Response> {
689689 if (created.error === "forbidden_owner" || created.error === "unauthorized")
690690 return new Response("forbidden", { status: 403 });
691691 const messages: Record<string, string> = {
692 repository_limit: `Not made: ${repositoryLimitMessage(ctx.app, ctx.auth.user)}.`,
692693 name_taken: "That owner already has a repository by that name.",
693694 invalid_name: "Names start with a letter or digit; no spaces, no .git.",
694695 git_init_failed: "The repository could not be initialised on disk.",
src/web/studio/make_it_yours.ts +2
@@ -128,6 +128,8 @@ export async function makeItYoursSubmit(ctx: Ctx): Promise<Response> {
128128 const message = !made.ok
129129 ? made.error.kind === "invalid"
130130 ? made.error.message
131 : made.error.kind === "repository_limit"
132 ? `Not made: ${made.error.message}.`
131133 : made.error.kind === "own"
132134 ? "This app is already yours."
133135 : made.error.kind === "unverified"
test/quotas.test.ts +180 −4
@@ -9,15 +9,22 @@ import { mkdtemp, rm } from "node:fs/promises";
99 import { tmpdir } from "node:os";
1010 import { join } from "node:path";
1111 import { createApp, type App } from "../src/app.ts";
12 import { newApp } from "../src/actions/apps.ts";
13 import { createAgent } from "../src/actions/crew.ts";
14 import { makeItYours } from "../src/actions/make_it_yours.ts";
15 import { createRepo, forkRepo } from "../src/actions/repos.ts";
16 import { getRepo } from "../src/core/repos.ts";
17 import { mirrorAndWait, newFakeState, serveFakeForge } from "./fake-origin.ts";
18 import { proveAll } from "./helpers/verified.ts";
1219 import { getQuotas, quotaChanges } from "../src/settings/quotas.ts";
1320 import { createToken } from "../src/core/tokens.ts";
14 import { newApp } from "../src/actions/apps.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { makeItYours } from "../src/actions/make_it_yours.ts";
17 import { getRepo } from "../src/core/repos.ts";
1821 import { createSession } from "../src/core/sessions.ts";
1922 import { createUser, type User } from "../src/core/users.ts";
2023 import { serve, type Servers } from "../src/http/server.ts";
24 import { createForgeApp } from "../src/tui/forge.ts";
25 import { newRepoScreen } from "../src/tui/screens/newrepo.ts";
26 import { headlessTerminal } from "../src/tui/term/terminal.ts";
27 import { settleApp } from "./helpers/tui.ts";
2128
2229 describe("quotas", () => {
2330 let dataDir: string;
@@ -317,3 +324,172 @@ describe("quotas", () => {
317324 }
outdated

These tests cover the action and the JSON API. They don't cover the HTML forms (site.ts newRepoSubmit, repo.ts forkSubmit, apps.ts, migrate.ts, studio make-it-yours) or the TUI screens. Add at least one form POST that expects "Not made: 2 of 2 repositories." and one TUI screen test, so both doors are proven.

318325 });
319326 });
327
328 describe("the repositories limit", () => {
329 let dataDir: string;
330 let app: App;
331 let servers: Servers;
332 let admin: string;
333 let fresh: string;
334 let freshUser: User;
335 let boss: User;
336
337 beforeAll(async () => {
338 dataDir = await mkdtemp(join(tmpdir(), "forge-quotas-limit-"));
339 app = createApp({ dataDir, port: 0 });
340 servers = await serve(app);
341 const users: User[] = [];
342 for (const username of ["boss", "fresh"]) {
343 const made = await createUser(app.db, {
344 trusted: true,
345 username,
346 email: `${username}@forge.local`,
347 password: "a-good-passphrase",
348 adminIfFirst: username === "boss",
349 });
350 if (!made.ok) throw new Error(made.error);
351 users.push(made.value);
352 }
353 freshUser = users[1]!;
354 boss = users[0]!;
355 admin = createToken(app.db, users[0]!.id, "t", "admin").token;
356 fresh = createToken(app.db, freshUser.id, "t", "rw").token;
357 });
358 afterAll(async () => {
359 await servers.stop();
360 await rm(dataDir, { recursive: true, force: true });
361 });
362
363 const post = (token: string, path: string, body: unknown) =>
364 fetch(`${servers.url}${path}`, {
365 method: "POST",
366 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
367 body: JSON.stringify(body),
368 });
369 const setLimit = (value: number | null) =>
370 fetch(`${servers.url}/api/v1/admin/settings`, {
371 method: "PATCH",
372 headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
373 body: JSON.stringify({ quotas: { repositories: value } }),
374 });
375
376 test("past the limit create and fork are refused with the sentence; admins and no limit are free", async () => {
377 // No limit: nothing changes.
378 expect((await post(admin, "/api/v1/repos", { name: "base", visibility: "public" })).status).toBe(201);
379 expect((await setLimit(2)).status).toBe(200);
380
381 for (const name of ["one", "two"])
382 expect((await post(fresh, "/api/v1/repos", { name })).status).toBe(201);
383 const third = await post(fresh, "/api/v1/repos", { name: "three" });
384 expect(third.status).toBe(403);
385 expect(await third.json()).toMatchObject({
386 error: "repository_limit",
387 message: "2 of 2 repositories",
388 });
389 const fork = await post(fresh, "/api/v1/repos/boss/base/forks", {});
390 expect(fork.status).toBe(403);
391 expect(((await fork.json()) as any).message).toBe("2 of 2 repositories");
392
393 // The owner (an admin) is never limited.
394 for (const name of ["a", "b", "c"])
395 expect((await post(admin, "/api/v1/repos", { name })).status).toBe(201);
396
397 // Clearing the limit lets the fresh account make one more.
398 expect((await setLimit(null)).status).toBe(200);
399 expect((await post(fresh, "/api/v1/repos", { name: "three" })).status).toBe(201);
400 });
401 test("a copy and a mirror say the sentence; an agent is not held to it", async () => {
402 // alice's public app, owned by the admin, for the copy.
403 const made = await newApp(app, boss, { name: "garden", visibility: "public" });
404 if (!made.ok) throw new Error(String(made.error));
405 expect((await setLimit(2)).status).toBe(200);
406 // fresh owns three here, so it is over the line.
407 const copy = await makeItYours(app, freshUser, made.value.repo, { dryRun: false });
408 expect(copy.ok).toBe(false);
409 if (!copy.ok) expect(copy.error).toEqual({ kind: "repository_limit", message: "3 of 2 repositories" });
410 const viaApi = await post(fresh, "/api/v1/repos/boss/garden/make-it-yours", { dryRun: false });
411 expect(viaApi.status).toBe(403);
412 expect(((await viaApi.json()) as any).message).toBe("3 of 2 repositories");
413
414 // A mirror is a repository too.
415 const state = newFakeState("forgejo", "someone", "tok");
416 state.repos.push({
417 id: 1,
418 fullName: "someone/gears",
419 cloneUrl: "http://127.0.0.1:1/someone/gears.git",
420 defaultBranch: "main",
421 });
422 const fake = serveFakeForge(state);
423 try {
424 proveAll(app);
425 const freshAdmin = createToken(app.db, freshUser.id, "o", "admin").token;
426 const connected = await post(freshAdmin, "/api/v1/user/origins", { host: fake.url, token: "tok" });
427 expect(connected.status).toBe(201);
428 const originId = ((await connected.json()) as { id: string }).id;
429 const job = await mirrorAndWait(
430 await post(freshAdmin, `/api/v1/user/origins/${originId}/mirror`, { fullName: "someone/gears" }),
431 );
432 expect(job?.stage).toBe("failed");
433 expect(job?.error).toBe("Not mirrored: 3 of 2 repositories.");
434 } finally {
435 fake.stop();
436 }
437
438 // An agent is held to nothing: it forks per issue to work.
439 const agent = await createAgent(app, boss, { username: "worker" });
440 if (!agent.ok) throw new Error(String(agent.error));
441 const forked = await forkRepo(app, agent.value.user, getRepo(app.db, "boss", "garden")!);
442 expect(forked.ok).toBe(true);
443
444 expect((await setLimit(null)).status).toBe(200);
445 });
446 test("owning exactly the limit is refused with 'N of N'; the pages and the terminal say so too", async () => {
447 const made = await createUser(app.db, {
448 trusted: true,
449 username: "exact",
450 email: "[email protected]",
451 password: "a-good-passphrase",
452 });
453 if (!made.ok) throw new Error(made.error);
454 const exact = made.value;
455 expect((await setLimit(2)).status).toBe(200);
456 for (const name of ["x1", "x2"]) expect((await createRepo(app, exact, { name })).ok).toBe(true);
457
458 const refused = await createRepo(app, exact, { name: "x3" });
459 expect(refused).toEqual({ ok: false, error: "repository_limit" });
460
461 // The new-repository page: a 422 with the sentence on the form.
462 const cookie = `forge_session=${createSession(app.db, exact.id).token}`;
463 const form = await fetch(`${servers.url}/new`, {
464 method: "POST",
465 headers: { cookie, origin: servers.url },
466 body: new URLSearchParams({ owner: "exact", name: "x3" }),
467 redirect: "manual",
468 });
469 expect(form.status).toBe(422);
470 expect(await form.text()).toContain("Not made: 2 of 2 repositories.");
471 expect(getRepo(app.db, "exact", "x3")).toBeUndefined();
472
473 // The terminal's new-repository screen: the same sentence, as a toast.
474 const terminal = headlessTerminal(120, 30);
475 const { app: tui, session } = createForgeApp(
476 app,
477 { sshUsername: "exact", remoteAddress: "127.0.0.1", fingerprint: "SHA256:exact", user: exact },
478 { terminal, exitHooks: false },
479 );
480 tui.start();
481 try {
482 tui.ctx.push(newRepoScreen(session, tui.ctx));
483 await settleApp(tui);
484 for (const ch of "x3")
485 tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
486 tui.dispatch({ type: "key", key: "d", ctrl: true, alt: false, shift: false });
487 await settleApp(tui);
488 expect(tui.screen.toText()).toContain("not made: 2 of 2 repositories");
489 } finally {
490 tui.stop();
491 }
492 expect(getRepo(app.db, "exact", "x3")).toBeUndefined();
493 expect((await setLimit(null)).status).toBe(200);
494 });
495 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs