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

An origin can be connected read-only, and every write-through path refuses it #10617

Merged opened this

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

Merged

scout/forge:scout/10519-an-origin-can-be-connected-read-only-and → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/10519-an-origin-can-be-connected-read-only-and was merged into main as 58bbeb7.

Review

Sign in to review this change.
22 files changed +354 −10
docs/features/origin-read-only.md added +9
@@ -0,0 +1,9 @@
1 # Connecting an origin read-only (#10519)
2
3 Tick **Read-only** when you connect an account by token (Settings → Origins, either form), pass `"readOnly": true` to `POST /api/v1/user/origins`, or answer `yes` to the last prompt of `c` in the terminal's Connected origins screen. Reading and syncing go on; nothing is ever written back: no comments, issues, reviews, branches, forks, proposals, pushes or mirror-out.
4
5 - **What you see:** the origins list says "read-only: nothing is written back" on the row (the terminal row says `read-only`). A refused write says so in a plain sentence; the REST door answers `409` with `"error": "origin_read_only"`.
6 - **Merging** on a mirror is never done from here, read-only or not (`mirrored`).
7 - **Stored:** `origin.read_only`, default 0, so every connection from before stays as it was. The token stays sealed either way.
8 - **Try it:** connect with Read-only ticked, mirror a repository, comment on it: refused, and the origin saw no write.
9 - Test: `test/origin-read-only.test.ts`.
src/actions/branches.ts +1
@@ -180,6 +180,7 @@ export async function originPush(
180180 if (!link) return ok(undefined);
181181 if (link.origin.userId !== actor.id) return err("origin_owner_only");
182182 if (link.cloneUrl === "") return err("origin_unreachable");
183 if (link.origin.readOnly) return err("origin_read_only");
183184 if (writesPaused(app, link.origin.id) !== undefined)
184185 return err("origin_paused");
185186 if (denied(link.origin, link.fullName)) return err("origin_protected");
src/actions/contribute.ts +3
@@ -75,6 +75,7 @@ export type ForkOriginError =
7575 | "origin_refused"
7676 /** The connection is not verified against the operator's rules yet. */
7777 | "origin_paused"
78 | "origin_read_only"
7879 /** The origin took the request and the fork never appeared in time. */
7980 | "fork_timeout";
8081
@@ -123,6 +124,7 @@ export async function forkOrigin(
123124 // Somebody else's connected account does not exist for you (invariant 9).
124125 const origin = getOriginFor(app.db, user.value.id, originId);
125126 if (!origin) return err("not_found");
127 if (origin.readOnly) return err("origin_read_only");
126128 if (writesPaused(app, origin.id) !== undefined) return err("origin_paused");
127129 const provider = providerForOrigin(app, origin);
128130 if (!provider.ok) return err("origin_unreachable");
@@ -271,6 +273,7 @@ export async function contribute(
271273 if (!proven.ok) return proven;
272274 const origin = getOriginFor(app.db, user.value.id, input.originId);
273275 if (!origin) return err("not_found");
276 if (origin.readOnly) return err("origin_read_only");
274277 if (writesPaused(app, origin.id) !== undefined) return err("origin_paused");
275278 const branch = input.branch.trim();
276279 if (!isSafeRef(branch) || branch.startsWith("refs/"))
src/actions/origins.ts +5
@@ -167,6 +167,8 @@ export interface ConnectInput {
167167 /** Set when the user picked a forge explicitly (GitHub Enterprise). */
168168 kind?: OriginKind;
169169 token: string;
170 /** Connect read-only: every write-through on its mirrors refuses. */
171 readOnly?: boolean;
170172 }
171173
172174 /**
@@ -232,6 +234,7 @@ export async function connectOrigin(
232234 token: input.token.trim(),
233235 authKind: "pat",
234236 software: softwareOf(found.value.software),
237 ...(input.readOnly === true ? { readOnly: true } : {}),
235238 });
236239 if (!stored.ok) return stored;
237240 if (guard.value.checked) noteGuardPassed(app, stored.value.id);
@@ -362,6 +365,7 @@ function storeOrigin(
362365 refreshToken?: string;
363366 expiresAt?: string | null;
364367 software?: string;
368 readOnly?: boolean;
365369 },
366370 ): Result<Origin, "already_connected"> {
367371 // Connecting an account whose credential died is re-authorizing it: the
@@ -406,6 +410,7 @@ function storeOrigin(
406410 : { refreshToken: input.refreshToken }),
407411 ...(input.expiresAt === undefined ? {} : { expiresAt: input.expiresAt }),
408412 ...(input.software === undefined ? {} : { software: input.software }),
413 ...(input.readOnly === true ? { readOnly: true } : {}),
409414 });
410415 if (!created.ok) return created;
411416 // The payload names the host and the login and nothing else: an audit
src/core/migration-pins/20261110090000-origin-read-only.pin added +1
@@ -0,0 +1,1 @@
1 75cb52dfd21642bc2a202621e2728eda05856f275a572575ba51f48bcacb913e
src/core/migrations/20261110090000-origin-read-only.ts added +5
@@ -0,0 +1,5 @@
1 // An origin connected read-only: every write-through on its mirrors refuses
2 // (`origin_read_only`). Off for every row from before. Additive.
3 export default `
4 ALTER TABLE origin ADD COLUMN read_only INTEGER NOT NULL DEFAULT 0;
5 `;
src/core/origins.ts +7 −2
@@ -36,6 +36,7 @@ interface OriginRow {
3636 expires_at: string | null;
3737 needs_reauth: number;
3838 software: string;
39 read_only: number;
3940 }
4041
4142 function toOrigin(row: OriginRow): Origin {
@@ -53,6 +54,7 @@ function toOrigin(row: OriginRow): Origin {
5354 expiresAt: row.expires_at,
5455 needsReauth: row.needs_reauth === 1,
5556 software: row.software,
57 readOnly: row.read_only === 1,
5658 };
5759 }
5860
@@ -72,6 +74,8 @@ export interface CreateOriginInput {
7274 expiresAt?: string | null;
7375 /** `forge` for another forge; see `Origin.software`. */
7476 software?: string;
77 /** Connect read-only; set once here and never cleared silently. @default false */
78 readOnly?: boolean;
7579 }
7680
7781 export type CreateOriginError = "already_connected";
@@ -97,8 +101,8 @@ export function createOrigin(
97101 const originId = id();
98102 db.query(
99103 `INSERT INTO origin (id, user_id, kind, api_url, web_url, login, token_enc,
100 auth_kind, refresh_token_enc, expires_at, software)
101 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)`,
104 auth_kind, refresh_token_enc, expires_at, software, read_only)
105 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)`,
102106 ).run(
103107 originId,
104108 input.userId,
@@ -113,6 +117,7 @@ export function createOrigin(
113117 : seal(dataDir, input.refreshToken, originId),
114118 input.expiresAt ?? null,
115119 input.software ?? "",
120 input.readOnly === true ? 1 : 0,
116121 );
117122 return ok(getOriginById(db, originId)!);
118123 }
src/http/api.ts +6
@@ -680,6 +680,12 @@ export function originAware(error: string, status = 422): Response {
680680 409,
681681 "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork",
682682 );
683 if (error === "origin_read_only")
684 return apiError(
685 "origin_read_only",
686 409,
687 "this account was connected read-only, so nothing is written through it; nothing was changed",
688 );
683689 if (error === "origin_paused")
684690 return apiError(
685691 "origin_paused",
src/http/api/user.ts +1
@@ -269,6 +269,7 @@ export function userRoutes(router: Router<Handler>): void {
269269 ? { kind }
270270 : {}),
271271 token: str(input.token),
272 readOnly: input.readOnly === true,
272273 });
273274 if (!connected.ok) {
274275 // A refusal that names its own reason (a missing scope) keeps it;
src/origins/credential.ts +6
@@ -72,6 +72,8 @@ export function credentialFor(
7272 software: origin.software,
7373 token: () => resolveToken(app, origin.id),
7474 writable: () => {
75 if (origin.readOnly)
76 return { ok: false, error: { error: "forbidden", message: READ_ONLY } };
7577 const paused = writesPaused(app, origin.id);
7678 return paused === undefined
7779 ? { ok: true, value: true }
@@ -247,6 +249,10 @@ const GUARD_GIVE_UP_MS = 24 * 60 * 60_000;
247249 /** The sweep's pause between requests: one origin at a time, gently. */
248250 const SWEEP_PAUSE_MS = 500;
249251
252 /** What a write through a read-only origin is told (`origin_read_only`), everywhere. */
253 export const READ_ONLY =
254 "This account was connected read-only, so nothing is written through it. Reading and syncing go on.";
outdated

Principle 8: lead with a plain sentence, not the error code. The code travels in the error field.

255
250256 /** What a write through a paused origin is told, everywhere. */
251257 export const WRITES_PAUSED =
252258 "This forge could not verify this connection against the operator's rules, so writes through it are paused. Reads and sync go on. Check it again under Settings → Origins.";
src/origins/fork-route.ts +2 −1
@@ -33,7 +33,7 @@ import type { Repo } from "../core/repos.ts";
3333 import { denied } from "./operator.ts";
3434 import { resolveOid } from "../git/merge.ts";
3535 import { err, ok, type Result } from "../kernel/result.ts";
36 import { writesPaused } from "./credential.ts";
36 import { READ_ONLY, writesPaused } from "./credential.ts";
3737 import { redact, secretsIn } from "./credentials.ts";
3838 import { pushToOrigin } from "./git-mirror.ts";
3939 import type { Provider, RemoteRepo } from "./types.ts";
@@ -243,6 +243,7 @@ export async function pushToFork(
243243 ): Promise<Result<string, string>> {
244244 // Its callers ask too; this is the push itself asking, so no path that
245245 // forgets cannot write through an origin whose account is not verified.
246 if (origin.readOnly) return err(READ_ONLY);
246247 const paused = writesPaused(app, origin.id);
247248 if (paused !== undefined) return err(paused);
248249 const tip = await resolveOid(dir, `refs/heads/${branch}`);
src/origins/push-through.ts +4 −1
@@ -37,7 +37,7 @@ import { FORWARDED_GIT_ENV, type RefUpdate } from "../git/hook-protocol.ts";
3737 import { fetchInto, resolveOid, writeRef } from "../git/merge.ts";
3838 import { repoDir } from "../git/repos.ts";
3939 import { err, ok, type Result } from "../kernel/result.ts";
40 import { providerForOrigin, writesPaused } from "./credential.ts";
40 import { providerForOrigin, READ_ONLY, writesPaused } from "./credential.ts";
4141 import { redact, secretsIn } from "./credentials.ts";
4242 import { pushToOrigin, snapshotRefs } from "./git-mirror.ts";
4343 import { denied } from "./operator.ts";
@@ -199,6 +199,8 @@ async function wayThrough(
199199 message: "this mirror has no upstream clone URL; re-mirror it",
200200 });
201201
202 if (link.origin.readOnly)
203 return err({ message: `${link.origin.webUrl}: ${READ_ONLY}` });
202204 const paused = writesPaused(app, link.origin.id);
203205 if (paused !== undefined)
204206 return err({ message: `${link.origin.webUrl}: ${paused}` });
@@ -258,6 +260,7 @@ export async function pushToMirrorTarget(
258260 };
259261 const origin = getOriginById(app.db, target.originId);
260262 if (!origin) return fail("the origin was disconnected; reconnect it");
263 if (origin.readOnly) return fail(READ_ONLY);
261264 const provider = providerForOrigin(app, origin);
262265 if (!provider.ok) return fail("the origin needs to be reconnected");
263266 const authEnv = await provider.value.gitAuthEnv(target.cloneUrl);
src/origins/types.ts +2
@@ -47,6 +47,8 @@ export interface Origin {
4747 * forge (stored as a Gitea origin, which is the dialect it speaks), else "".
4848 */
4949 readonly software: string;
50 /** Connected read-only: nothing is ever written through it (`origin_read_only`). */
51 readonly readOnly: boolean;
5052 }
5153
5254 /** A repository as the origin describes it — the import picker's rows. */
src/origins/write-through.ts +3
@@ -24,6 +24,8 @@ export type WriteThroughError =
2424 | "origin_refused"
2525 /** The account behind the origin is not verified against the operator's rules yet. */
2626 | "origin_paused"
27 /** The origin was connected read-only: nothing is written through it. */
28 | "origin_read_only"
2729 /** The operator protects the origin repository: propose from a fork. */
2830 | "origin_protected";
2931
@@ -53,6 +55,7 @@ export function writeThroughFor(
5355 const link = originForRepo(app.db, repo.id);
5456 if (!link) return ok(undefined);
5557 if (link.origin.userId !== actor.id) return err("origin_owner_only");
58 if (link.origin.readOnly) return err("origin_read_only");
5659 if (writesPaused(app, link.origin.id) !== undefined)
5760 return err("origin_paused");
5861 const provider = providerForOrigin(app, link.origin);
src/tui/screens/common.ts +1
@@ -982,6 +982,7 @@ const MESSAGES: Record<string, string> = {
982982 mirrored: "this repo mirrors an origin — do it there",
983983 origin_owner_only: "only whoever connected this origin can write through it",
984984 origin_refused: "the origin refused that; nothing changed here",
985 origin_read_only: "this account was connected read-only; nothing is written through it",
985986 origin_paused: "this connection is not verified yet; writes are paused",
986987 origin_protected: "the operator protects this repository at its origin: nothing is pushed to it from here; propose the change from your fork",
987988 origin_unreachable: "the origin could not be reached; nothing changed",
src/tui/screens/connections.ts +16 −6
@@ -55,7 +55,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
5555 return {
5656 title: `${host(o.webUrl)} · ${o.login}`,
5757 ...(o.lastError !== "" || writesPaused(f.app, o.id) !== undefined ? { tone: "danger" as const } : {}),
58 secondary: `${o.software === "forge" ? "another forge · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
58 secondary: `${o.software === "forge" ? "another forge · " : ""}${o.readOnly ? "read-only · " : ""}${k} repositor${k === 1 ? "y" : "ies"} · ${day(o.lastSyncAt)}`,
5959 meta: o.lastError !== "" ? o.lastError : writesPaused(f.app, o.id) !== undefined ? "writes paused" : o.authKind === "oauth" ? "OAuth" : "token",
6060 };
6161 }),
@@ -81,11 +81,21 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
8181 mask: true,
8282 onSubmit: (token) => {
8383 if (token.trim() === "") return "Paste the token.";
84 ctx.toast(`Connecting ${hostText}…`);
85 return connectOrigin(f.app, user, { host: hostText, token }).then((r) => {
86 ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}.` : connectRefusal(r.error).message ?? connectWords(connectRefusal(r.error).code), r.ok ? "success" : "danger");
87 ctx.invalidate();
88 });
84 return later(ctx, () =>
85 promptScreen(ctx, screen, {
86 title: "Connect an origin",
87 label: "Read-only? Type yes to never write back to it",
88 hint: "Leave empty to connect the ordinary way.",
89 onSubmit: (answer) => {
90 const readOnly = /^y(es)?$/i.test(answer.trim());
91 ctx.toast(`Connecting ${hostText}…`);
92 return connectOrigin(f.app, user, { host: hostText, token, readOnly }).then((r) => {
93 ctx.toast(r.ok ? `Connected ${r.value.login} at ${host(r.value.webUrl)}${readOnly ? ", read-only" : ""}.` : connectRefusal(r.error).message ?? connectWords(connectRefusal(r.error).code), r.ok ? "success" : "danger");
94 ctx.invalidate();
95 });
96 },
97 }),
98 );
8999 },
90100 }),
91101 );
src/web/pages/browse.ts +2
@@ -950,6 +950,8 @@ export function forkFailure(error: string): string {
950950 return "The origin could not be reached. Nothing was created.";
951951 if (error === "origin_refused")
952952 return "The origin refused to fork that repository.";
953 if (error === "origin_read_only")
954 return "This account was connected read-only, so nothing is written through it.";
953955 if (error === "origin_paused")
954956 return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins.";
955957 if (error === "mirror_failed")
src/web/pages/edit.ts +2
@@ -128,6 +128,8 @@ export function editErrorText(error: unknown): string {
128128 return "The origin refused this change, so nothing landed here either.";
129129 if (error === "origin_protected")
130130 return "The operator protects this repository at its origin, so nothing is pushed to it from here. Propose the change from your fork.";
131 if (error === "origin_read_only")
132 return "This account was connected read-only, so nothing is written through it.";
131133 if (error === "origin_paused")
132134 return "This connection is not verified yet, so writes through it are paused. Check it again under Settings → Origins.";
133135 return "That edit could not be saved.";
src/web/pages/origins.ts +15
@@ -281,6 +281,7 @@ function originCard(origin: Origin, mirrors: number, paused = false): Html {
281281 origin.webUrl,
282282 )}`,
283283 meta: html`${origin.authKind === "oauth" ? "OAuth" : "personal access token"}
284 ${when(origin.readOnly, () => html` · read-only: nothing is written back`)}
284285 ·
285286 ${origin.lastSyncAt === null
286287 ? html`never synced`
@@ -591,11 +592,23 @@ function tokenSection(values: ConnectValues): Html {
591592 autocomplete: "off",
592593 hint: "It is never displayed after this.",
593594 })}
595 ${readOnlyField()}
594596 ${tokenNeeds(values.tokenHost)}
595597 <div class="form-actions">${btn("Connect")}</div>
596598 </form>`;
597599 }
598600
601 /** The one Read-only checkbox, on every way to connect by token. */
602 function readOnlyField(): Html {
603 return uiField({
604 label: "Read-only",
605 name: "readOnly",
606 type: "checkbox",
607 value: "1",
608 hint: "Nothing is ever written back to that account: no comments, pulls, pushes or merges.",
609 });
610 }
611
599612 const OLDER_FORGE_DOC = "/trevato/forge/blob/main/docs/features/forge-origin-older.md#connecting-from-an-older-forge";
600613
601614 /** The one line for a forge older than 0.7.346 (#9626): it cannot start a connection, this side can. */
@@ -668,6 +681,7 @@ function forgeSection(ctx: Ctx, values: ConnectValues): Html {
668681 autocomplete: "off",
669682 hint: "Read is enough to bring an app; private repositories come with it.",
670683 })}
684 ${readOnlyField()}
671685 <p class="muted small">Make the token there: ${tokens} (Settings → Access tokens).</p>
672686 <div class="form-actions">
673687 ${btn("Connect that forge", { variant: "primary", icon: "globe" })}
@@ -972,6 +986,7 @@ export async function originConnectToken(ctx: Ctx): Promise<Response> {
972986 const connected = await connectOrigin(ctx.app, ctx.auth.user, {
973987 host,
974988 token,
989 readOnly: field(form, "readOnly") === "1",
975990 });
976991 if (!connected.ok) {
977992 // The host comes back; the token deliberately does not. Re-typing a
test/origin-read-only.test.ts added +259
@@ -0,0 +1,259 @@
1 /**
2 * An origin connected read-only (#10519): every write-through on a repo
3 * mirrored from it answers `origin_read_only` before a request leaves the
4 * forge, and a read (sync) still works. The fake remote records every request
5 * it receives, so "refused" is proved by "the remote saw no write".
6 */
7 import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { forkOrigin, contribute } from "../src/actions/contribute.ts";
13 import { createBranch } from "../src/actions/branches.ts";
14 import { addCommentThrough, openIssueThrough, setIssueLabelsThrough, setIssueStateThrough } from "../src/actions/issues.ts";
15 import { mergePull, openPull, submitReview } from "../src/actions/pulls.ts";
16 import { createSession } from "../src/core/sessions.ts";
17 import { getOriginById, listOrigins } from "../src/core/origins.ts";
18 import { setMirrorTarget } from "../src/core/migrate_mirror.ts";
19 import { forgetProviders } from "../src/origins/providers/index.ts";
20 import { getRepo } from "../src/core/repos.ts";
21 import { getUserByName } from "../src/core/users.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { pushThrough, pushToMirrorTarget } from "../src/origins/push-through.ts";
24 import { syncsSettled } from "../src/origins/sync.ts";
25 import { mirrorAndWait, newFakeState, serveFakeForge, type FakeForge } from "./fake-origin.ts";
26 import { seedAdmin } from "./helpers/admin.ts";
27 import { proveAll } from "./helpers/verified.ts";
28
29 setDefaultTimeout(60_000);
30
31 const LOGIN = "octo";
32 const TOKEN = "octo-readonly-secret-5d1c";
33 const PASSWORD = "alice-login-secret";
34 const basic = (u: string, p: string) => `Basic ${Buffer.from(`${u}:${p}`).toString("base64")}`;
35
36 describe("an origin connected read-only", () => {
37 const dirs: string[] = [];
38 let app: App;
39 let servers: Servers;
40 let originApp: App;
41 let originServers: Servers;
42 let fake: FakeForge;
43 let originId = "";
44
45 const api = (path: string, init?: RequestInit) => {
46 const headers = new Headers(init?.headers);
47 headers.set("content-type", "application/json");
48 headers.set("authorization", basic("alice", PASSWORD));
49 return fetch(`${servers.url}${path}`, { ...init, headers });
50 };
51 const writes = () => fake.state.requests.filter((r) => r.method !== "GET" && r.method !== "HEAD");
52 const alice = () => getUserByName(app.db, "alice")!;
53 const repo = () => getRepo(app.db, "alice", "vault")!;
54 const ghost = (n = 1): any => ({ repoId: repo().id, number: n });
55
56 beforeAll(async () => {
57 for (const p of ["forge-ro-", "forge-ro-up-"]) dirs.push(await mkdtemp(join(tmpdir(), p)));
58 originApp = createApp({ dataDir: dirs[1]!, port: 0 });
59 originServers = await serve(originApp);
60 await seedAdmin(originApp, { username: LOGIN, email: "[email protected]", password: TOKEN });
61 const oh = { "content-type": "application/json", authorization: basic(LOGIN, TOKEN) };
62 await fetch(`${originServers.url}/api/v1/repos`, {
63 method: "POST",
64 headers: oh,
65 body: JSON.stringify({ name: "vault", visibility: "public" }),
66 });
67 await fetch(`${originServers.url}/api/v1/repos/octo/vault/contents`, {
68 method: "POST",
69 headers: oh,
70 body: JSON.stringify({ branch: "main", message: "first", files: [{ path: "a.txt", content: "one\n" }] }),
71 });
72 const state = newFakeState("github", LOGIN, TOKEN);
73 state.repos.push({
74 id: 9,
75 fullName: "octo/vault",
76 cloneUrl: `${originServers.url}/octo/vault.git`,
77 defaultBranch: "main",
78 htmlUrl: `${originServers.url}/octo/vault`,
79 push: true,
80 });
81 fake = serveFakeForge(state);
82
83 app = createApp({ dataDir: dirs[0]!, port: 0 });
84 servers = await serve(app);
85 const made = await seedAdmin(app, { username: "alice", email: "[email protected]", password: PASSWORD });
86 if (!made.ok) throw new Error(made.error);
87 const connected = (await (
88 await api("/api/v1/user/origins", {
89 method: "POST",
90 body: JSON.stringify({ host: fake.url, kind: "github", token: TOKEN, readOnly: true }),
91 })
92 ).json()) as { id: string };
93 originId = connected.id;
94 await mirrorAndWait(
95 await api(`/api/v1/user/origins/${originId}/mirror`, {
96 method: "POST",
97 body: JSON.stringify({ fullName: "octo/vault" }),
98 }),
99 );
100 proveAll(app);
101 });
102
103 afterAll(async () => {
104 await syncsSettled();
105 fake.stop();
106 servers.stop();
107 originServers.stop();
108 app.close();
109 originApp.close();
110 for (const d of dirs) await rm(d, { recursive: true, force: true });
111 });
112
113 test("the flag is stored, the token is still sealed and never shown", async () => {
114 expect(getOriginById(app.db, originId)?.readOnly).toBe(true);
115 const row = app.db.query<{ token_enc: string }, [string]>("SELECT token_enc FROM origin WHERE id = ?1").get(originId)!;
116 expect(row.token_enc).not.toContain(TOKEN);
117 const listed = await (await api("/api/v1/user/origins")).text();
118 expect(listed).not.toContain(TOKEN);
119 });
120
121 test("a read (sync) still succeeds", async () => {
122 const res = await api("/api/v1/repos/alice/vault/sync", { method: "POST" });
123 expect(res.status).toBeLessThan(300);
124 await syncsSettled();
125 });
126
127 test("every write-through refuses and the remote sees no write", async () => {
128 const before = writes().length;
129 const r = repo();
130 const a = alice();
131 const refused = (result: { ok: boolean; error?: unknown }) => {
132 expect(result.ok).toBe(false);
133 expect(result.error).toBe("origin_read_only");
134 };
135
136 refused(await openIssueThrough(app, a, r, { title: "hello" }));
137 refused(await addCommentThrough(app, a, r, ghost(), "hi"));
138 refused(await setIssueStateThrough(app, a, r, ghost(), "closed"));
139 refused(await setIssueLabelsThrough(app, a, r, ghost(), ["bug"]));
140 refused(await openPull(app, a, r, { title: "t", baseBranch: "main", headBranch: "x" }));
141 refused(await submitReview(app, a, r, { ...ghost(), baseBranch: "main", headBranch: "main", crossRepo: false, sealed: false, baseOid: "", headOid: "" } as never, { state: "approved", body: "" }));
142 refused(await createBranch(app, a, r, { name: "feature", from: "main" } as never));
143 refused(await forkOrigin(app, a, originId, "octo/vault"));
144 refused(
145 await contribute(app, a, {
146 originId,
147 fullName: "octo/vault",
148 branch: "work",
149 files: [{ path: "x.txt", content: "x" }],
150 } as never),
151 );
152
153 // Merging on a mirror is refused outright, whatever the origin's flag.
154 const merged = await mergePull(app, a, r, { ...ghost(), originHeadRepo: null } as never, "merge");
155 expect(merged.ok).toBe(false);
156
157 // A push forwarded to the origin.
158 const pushed = await pushThrough(app, r, [{ ref: "refs/heads/main", oldOid: "0".repeat(40), newOid: "1".repeat(40) }], {}, a.id);
159 expect(pushed.ok).toBe(false);
160 if (!pushed.ok) expect(pushed.error.message).toContain("connected read-only");
161
162 // Mirror-out.
163 setMirrorTarget(app.db, { repoId: r.id, originId, fullName: "octo/vault", cloneUrl: "http://127.0.0.1:1/x.git", tips: {} });
164 const out = await pushToMirrorTarget(app, r);
165 expect(out.ok).toBe(false);
166 if (!out.ok) expect(out.error.message).toContain("connected read-only");
167
168 expect(writes().length).toBe(before);
169 });
outdated

Vacuous: it edits the column and reads it back. Connect a second origin without readOnly and assert a write is not refused with origin_read_only.

170
171 test("merging on a mirror is refused outright, with its own plain answer", async () => {
172 const merged = await mergePull(app, alice(), repo(), { ...ghost(), originHeadRepo: null } as never, "merge");
173 expect(merged.ok).toBe(false);
174 if (!merged.ok) expect(merged.error).toBe("mirrored");
175 });
176
177 test("the REST door answers 409 origin_read_only", async () => {
178 const res = await api("/api/v1/repos/alice/vault/issues", {
179 method: "POST",
180 body: JSON.stringify({ title: "nope" }),
181 });
182 expect(res.status).toBe(409);
183 const body = (await res.json()) as { error: string; message: string };
184 expect(body.error).toBe("origin_read_only");
185 expect(body.message).not.toContain("origin_read_only");
186 });
187
188 test("an origin connected the ordinary way is not refused as read-only", async () => {
189 const state = newFakeState("github", "plain", "plain-secret-77");
190 state.repos.push({
191 id: 10,
192 fullName: "plain/vault2",
193 cloneUrl: `${originServers.url}/octo/vault.git`,
194 defaultBranch: "main",
195 htmlUrl: `${originServers.url}/octo/vault`,
196 push: true,
197 });
198 const plain = serveFakeForge(state);
199 try {
200 const made = (await (
201 await api("/api/v1/user/origins", {
202 method: "POST",
203 body: JSON.stringify({ host: plain.url, kind: "github", token: "plain-secret-77" }),
204 })
205 ).json()) as { id: string };
206 expect(getOriginById(app.db, made.id)?.readOnly).toBe(false);
207 await mirrorAndWait(
208 await api(`/api/v1/user/origins/${made.id}/mirror`, {
209 method: "POST",
210 body: JSON.stringify({ fullName: "plain/vault2" }),
211 }),
212 );
213 proveAll(app);
214 const second = getRepo(app.db, "alice", "vault2")!;
215 const result = await openIssueThrough(app, alice(), second, { title: "hello" });
216 if (!result.ok) expect(result.error).not.toBe("origin_read_only");
217 expect(plain.state.requests.some((r) => r.method === "POST")).toBe(true);
218 } finally {
219 await syncsSettled();
220 plain.stop();
221 }
222 });
223
224 test("a row from before the migration is off: the column defaults to 0", () => {
225 const col = app.db
226 .query<{ name: string; notnull: number; dflt_value: string }, []>("PRAGMA table_info(origin)")
227 .all()
228 .find((c) => c.name === "read_only")!;
229 expect(col.notnull).toBe(1);
230 expect(col.dflt_value).toBe("0");
231 });
232
233 test("the web form: readOnly=1 stores the flag, the list shows it, unticked stores none", async () => {
234 const cookie = `forge_session=${createSession(app.db, alice().id).token}`;
235 const connect = async (login: string, extra: Record<string, string>) => {
236 forgetProviders(dirs[0]!);
237 const state = newFakeState("forgejo", login, `${login}-tok-123`);
238 const f = serveFakeForge(state);
239 try {
240 const res = await fetch(`${servers.url}/settings/origins/token`, {
241 method: "POST",
242 headers: { cookie, origin: new URL(servers.url).origin },
243 body: new URLSearchParams({ host: f.url, token: `${login}-tok-123`, ...extra }),
244 redirect: "manual",
245 });
246 expect(res.status).toBe(303);
247 } finally {
248 f.stop();
249 }
250 return listOrigins(app.db, alice().id).find((o) => o.login === login)!;
251 };
252 const ticked = await connect("webro", { readOnly: "1" });
253 expect(ticked.readOnly).toBe(true);
254 const unticked = await connect("webrw", {});
255 expect(unticked.readOnly).toBe(false);
256 const page = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text();
257 expect(page).toContain("read-only: nothing is written back");
258 });
259 });
test/tui-origins-forge.test.ts +3
@@ -86,6 +86,8 @@ test("c connects another forge by address and token, and the list says what did
8686 await type(friendToken);
8787 expect(app.screen.toText()).not.toContain(friendToken);
8888 await key("enter");
89 expect(app.screen.toText()).toContain("Read-only?");
90 await key("enter");
8991 for (let i = 0; i < 50 && listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0; i++) await Bun.sleep(100);
9092 if (listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0) throw new Error(`never came: still waiting while listOrigins(mine.db, getUserByName(mine.db, "alice")!.id).length === 0`);
9193 await settleApp(app);
@@ -128,6 +130,7 @@ test("a token the forge refuses connects nothing and says so", async () => {
128130 await press("enter");
129131 await type("forge_not_a_token");
130132 await press("enter");
133 await press("enter");
131134 for (let i = 0; i < 30 && !app.screen.toText().includes("refused"); i++) {
132135 await Bun.sleep(100);
133136 await settleApp(app);
test/tui-waves.test.ts +1
@@ -54,6 +54,7 @@ const remoteOf = (src: RepoSource): RepoSource => ({
5454 authKind: "pat",
5555 expiresAt: null,
5656 needsReauth: false,
57 readOnly: false,
5758 software: "",
5859 },
5960 fullName: "someone/else",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs