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

An admin turns following on in settings and checks now, the repo page says what it follows, and a check runs every 30 minutes #4218

Merged opened this

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

Merged

scout/forge:scout/3980-an-admin-turns-following-on-in-settings → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/3980-an-admin-turns-following-on-in-settings was merged into main as e908d75.

Review

Sign in to review this change.
11 files changed +562 −7
docs/ORIGINS.md +30
@@ -534,6 +534,36 @@ The clone runs as a job in the mirror's registry (`origins/jobs.ts`, kind
534534 app and the crew's first run — and then lands on the repository, which is
535535 the app's page, with what happened as the flash.
536536
537 ## Follow an upstream
538
539 A repository can follow a git URL outside the forge (`repo_upstream`,
540 `core/upstream_follow.ts`; the check is `actions/upstream_follow.ts`). An
541 admin of the repository does it, on its settings page's **Follow upstream**
542 card: the URL, the branch (default `main`), and an on/off box; **Check
543 upstream now** appears once it is on. The URL is `https:` or `http:` with no
544 password in it — `git:`, `ssh:`, `file:` and the rest are refused at save,
545 so the vetted-address pin always holds. A host inside the network is checked
546 as the mirror's fetch is: vetted, and the vetted address pinned.
547
548 Every 30 minutes (the server's one-minute timer runs `checkDue`) and on
549 **Check now**: `ls-remote`, a fetch only when the tip moved into
550 `refs/upstream-follow/<branch>` — not a branch — and, when the commits merge
551 cleanly, one pull from `upstream/<tip7>`. One check runs per repository at a
552 time, the clock's and the admin's sharing one claim; a second is `busy`
553 (409 in the API). Never a push to the default branch.
554
555 **Credentials.** A public upstream needs none. A private one is fetched with
556 the credential of the origin the admin connected (Settings → Origins), sent
557 only to the exact https origin it was connected at, as a header in git's
558 environment — never in the URL, never in argv. Nothing is stored in the
559 repository. A check's failure message is the remote's stderr with secrets
560 redacted, shown to admins only.
561
562 The About card says "Following <upstream> · last checked HH:MM · N behind",
563 to admins only. API, all admin-only (anyone else gets 404):
564 `GET|PUT /api/v1/repos/:owner/:name/upstream` (PUT takes
565 `{url, branch, enabled}`) and `POST …/upstream/check`.
566
537567 ## API
538568
539569 ```
src/actions/upstream_follow.ts +125 −5
@@ -21,12 +21,19 @@ import type { App } from "../app.ts";
2121 import { getIssueById, updateIssueText } from "../core/issues.ts";
2222 import { getOriginFor } from "../core/origins.ts";
2323 import { pullForBranch, retargetPull } from "../core/pulls.ts";
24 import { canWrite, getRepoById, type Repo } from "../core/repos.ts";
24 import { canAdmin, canWrite, getRepoById, type Repo } from "../core/repos.ts";
2525 import { storageStale } from "../core/storage.ts";
26 import { getFollow, listFollowing, noteCheck } from "../core/upstream_follow.ts";
27 import { getUserById } from "../core/users.ts";
26 import {
27 getFollow,
28 listFollowing,
29 noteCheck,
30 putFollow,
31 upstreamUrl,
32 type UpstreamFollow,
33 } from "../core/upstream_follow.ts";
34 import { getUserById, type User } from "../core/users.ts";
2835 import { runGitCapture } from "../git/env.ts";
29 import { isSafeOid } from "../git/guard.ts";
36 import { isSafeOid, isSafeRef } from "../git/guard.ts";
3037 import { aheadCount, mergeable, resolveOid, writeRef } from "../git/merge.ts";
3138 import { REMOTE_PROTOCOLS } from "../git/remote-env.ts";
3239 import { repoDir } from "../git/repos.ts";
@@ -34,6 +41,7 @@ import { err, ok, type Result } from "../kernel/result.ts";
3441 import { providerForOrigin } from "../origins/credential.ts";
3542 import { redact, secretsIn } from "../origins/credentials.ts";
3643 import { vettedEnv } from "../origins/git-mirror.ts";
44 import { migrateUrl } from "./migrate.ts";
3745 import { openPull } from "./pulls.ts";
3846
3947 /** How often a following repository is checked on its own. */
@@ -95,7 +103,11 @@ export async function checkUpstream(app: App, repo: Repo): Promise<Result<Checke
95103 if (checking.has(repo.id)) return err("busy");
96104 checking.add(repo.id);
97105 try {
98 return await checkOnce(app, repo);
106 const result = await checkOnce(app, repo);
107 // So an open settings page or About card updates; no address in it.
108 if (result.ok || result.error !== "off")
109 app.bus.append({ kind: "repo.upstream_checked", repoId: repo.id, payload: {} });

New behaviour with no test. Assert that one repo.upstream_checked event is appended with payload: {} after a check, and that none is appended when following is off.

110 return result;
99111 } finally {
100112 checking.delete(repo.id);
101113 }
@@ -232,3 +244,111 @@ async function checkOnce(app: App, repo: Repo): Promise<Result<Checked, CheckErr
232244 noteCheck(app.db, repo.id, { at, tip: staged, behind, error: "" });
233245 return ok({ tip: staged, behind, outcome: "proposed", pull: number });
234246 }
247
248 export type FollowError = "invalid_url" | "invalid_branch" | "not_found" | "unauthorized" | "forbidden";
249
250 /** What each refusal of a save says, at every door; a check's are `CHECK_REFUSALS`. */
251 export const FOLLOW_REFUSALS: Record<FollowError | CheckError, string> = {
252 ...CHECK_REFUSALS,
253 invalid_url: "The upstream is an https or http git address, with no password in it.",
254 invalid_branch: "That is not a branch name git accepts.",
255 not_found: "No such repository.",
256 unauthorized: "Sign in first.",
257 forbidden: "Only an admin of this repository follows an upstream.",
258 };
259
260 /** The follow row, for an admin of the repository and for nobody else. */
261 export function followShown(app: App, repo: Repo, actor: User | undefined): UpstreamFollow | undefined {
262 if (actor === undefined || !canAdmin(app.db, repo, actor)) return undefined;
263 return getFollow(app.db, repo.id);
264 }
265
266 /** Point a repository at an upstream and turn following on or off. Admins only. */
267 export function setFollowUpstream(
268 app: App,
269 actor: User | undefined,
270 repo: Repo,
271 input: { readonly url: string; readonly branch: string; readonly enabled: boolean },
272 ): Result<UpstreamFollow, FollowError> {
273 if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("not_found");
274 const was = getFollow(app.db, repo.id);
275 // Turning it off needs no address: the row keeps the last one.
276 const blank = input.url.trim() === "";
277 const raw = blank && was !== undefined ? was.url : input.url;
278 const normal = migrateUrl(raw);
279 const url = normal === undefined ? undefined : upstreamUrl(normal);
280 if (url === undefined) return err("invalid_url");
281 const branch = input.branch.trim() === "" ? (was?.branch ?? "main") : input.branch.trim();
282 if (!isSafeRef(branch) || branch.length > 200) return err("invalid_branch");
283 const saved = putFollow(app.db, {
284 repoId: repo.id,
285 url,
286 branch,
287 enabled: input.enabled,
288 byId: actor.id,
289 originId: was?.url === url ? was.originId : "",
290 });
291 app.bus.append({
292 kind: "repo.upstream_follow",
293 repoId: repo.id,
294 actorId: actor.id,
295 payload: { enabled: saved.enabled, branch: saved.branch },
296 });
297 return ok(saved);
298 }
299
300 /** "Check upstream now": an admin's, through the same claim as the clock. */
301 export async function checkUpstreamNow(
302 app: App,
303 actor: User | undefined,
304 repo: Repo,
305 ): Promise<Result<Checked, FollowError | CheckError>> {
306 if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("not_found");
307 return checkUpstream(app, repo);
308 }
309
310 /** The follow row as the API shows it. */
311 export function followJson(f: UpstreamFollow): Record<string, unknown> {
312 return {
313 url: f.url,
314 branch: f.branch,
315 enabled: f.enabled,
316 checked_at: f.checkedAt,
317 tip: f.tip === "" ? null : f.tip,
318 behind: f.behind,
319 error: f.error === "" ? null : f.error,
320 pending_id: f.pendingId,
321 opened: f.opened,
322 };
323 }
324
325 /** `host/path` of an upstream, for a line of prose. */
326 export function upstreamLabel(url: string): string {
327 try {
328 const u = new URL(url);
329 return `${u.host}${u.pathname}`.replace(/\.git$/, "").replace(/\/$/, "");
330 } catch {
331 return url;
332 }
333 }
334
335 /** HH:MM, UTC. */
336 export const clockWords = (iso: string): string => {
337 const at = new Date(iso);
338 return Number.isNaN(at.getTime()) ? "" : at.toISOString().slice(11, 16);
339 };
340
341 /** Where a following stands: what the last check read. */
342 export function standing(f: UpstreamFollow): string {
343 if (!f.enabled) return "off";
344 if (f.error !== "") return f.error === "conflict" ? `${f.behind} behind, conflicts` : "last check failed";
345 if (f.checkedAt === null) return "not checked yet";
346 return f.behind === 0 ? "up to date" : `${f.behind} behind`;
347 }
348
349 /** What "Check upstream now" found, in a sentence. */
350 export function checkedWords(c: Checked): string {
351 if (c.outcome === "none") return "Up to date: nothing new upstream.";
352 if (c.outcome === "conflict") return `${c.behind} new upstream commits conflict with the default branch.`;
353 return `${c.behind} new upstream commit${c.behind === 1 ? "" : "s"} proposed${c.pull === undefined ? "" : ` in #${c.pull}`}.`;
354 }
src/core/event_audience.ts +1
@@ -163,6 +163,7 @@ export function classifiedAudience(kind: string): EventAudience | undefined {
163163 // Where a repository follows is its admins' to read, off or on: the
164164 // address is often a private repository's.
165165 kind === "repo.upstream_follow" ||
166 kind === "repo.upstream_checked" ||
166167 kind === "repo.crew_outsiders"
167168 )
168169 return "admins";
src/design/activity.ts +1
@@ -120,6 +120,7 @@ const WORDS: Record<string, string> = {
120120 "repo.created": "created a repository",
121121 "repo.forked": "forked a repository",
122122 "repo.settings_updated": "changed repository settings",
123 "repo.upstream_follow": "changed which upstream the repository follows",
123124 "repo.crew_outsiders": "changed who may trigger the crew",
124125 "repo.mirrored": "mirrored a repository",
125126 "repo.imported": "imported issues and pulls",
src/http/api.ts +51
@@ -8,6 +8,13 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import {
12 checkUpstreamNow,
13 FOLLOW_REFUSALS,
14 followJson,
15 setFollowUpstream,
16 } from "../actions/upstream_follow.ts";
17 import { getFollow } from "../core/upstream_follow.ts";
1118 import { showProvenance } from "../actions/provenance.ts";
1219 import { getProvenance, provenanceFacts } from "../core/provenance.ts";
1320 import { goldenPath } from "../apps/golden_path.ts";
@@ -2366,6 +2373,50 @@ export function apiRoutes(router: Router<Handler>): void {
23662373 return res;
23672374 });
23682375
2376 // Following an outside upstream (#3442): an admin reads, sets and checks.
2377 router.add("GET", "/api/v1/repos/:owner/:name/upstream", (ctx) => {
2378 const denied = requireScope(ctx, "ro");
2379 if (denied) return denied;
2380 const repo = loadRepo(ctx);
2381 if (!repo) return notFound();
2382 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return notFound();
2383 const follow = getFollow(ctx.app.db, repo.id);
2384 return json({ following: follow === undefined ? null : followJson(follow) });
2385 });
2386
2387 router.add("PUT", "/api/v1/repos/:owner/:name/upstream", async (ctx) => {
2388 const denied = requireScope(ctx, "admin");
2389 if (denied) return denied;
2390 const repo = loadRepo(ctx);
2391 if (!repo) return notFound();
2392 const input = await body(ctx.request);
2393 if (typeof input.enabled !== "boolean")
2394 return apiError("bad_enabled", 422, "enabled is true or false");
2395 const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, {
2396 url: str(input.url),
2397 branch: str(input.branch),
2398 enabled: input.enabled,
2399 });
2400 if (!saved.ok)
2401 return saved.error === "not_found" || saved.error === "unauthorized" || saved.error === "forbidden"
2402 ? respondAccess(saved.error, 422)
2403 : apiError(saved.error, 422, FOLLOW_REFUSALS[saved.error]);
2404 return json({ following: followJson(saved.value) });
2405 });
2406
2407 router.add("POST", "/api/v1/repos/:owner/:name/upstream/check", async (ctx) => {
2408 const denied = requireScope(ctx, "admin");
2409 if (denied) return denied;
2410 const repo = loadRepo(ctx);
2411 if (!repo) return notFound();
2412 const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo);
2413 if (!checked.ok)
2414 return checked.error === "not_found" || checked.error === "unauthorized" || checked.error === "forbidden"
2415 ? respondAccess(checked.error, 422)
2416 : apiError(checked.error, checked.error === "busy" ? 409 : 422, FOLLOW_REFUSALS[checked.error]);
2417 return json(checked.value);
2418 });
2419
23692420 router.add("PATCH", "/api/v1/repos/:owner/:name", async (ctx) => {
23702421 const denied = requireScope(ctx, "admin");
23712422 if (denied) return denied;
src/http/server.ts +3
@@ -5,6 +5,7 @@
55 * verdict. Auth is resolved once per request and handed to handlers as data.
66 */
77
8 import { followUpstreamCheck, followUpstreamUpdate } from "../web/pages/upstream-follow.ts";
89 import { refusedByDatabase, shownRefusal } from "./refusal.ts";
910 import { NOT_A_MEMBER } from "../core/roles.ts";
1011 import { signupOptions, signupPasskey } from "../web/pages/signup-passkey.ts";
@@ -1408,6 +1409,8 @@ export function buildRouter(): Router<Handler> {
14081409 );
14091410 router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage);
14101411 router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate);
1412 router.add("POST", "/:owner/:name/settings/upstream", followUpstreamUpdate);
1413 router.add("POST", "/:owner/:name/settings/upstream/check", followUpstreamCheck);
14111414 router.add("GET", "/:owner/:name/settings/labels", labelsPage);
14121415 router.add("POST", "/:owner/:name/settings/labels", labelCreate);
14131416 router.add("POST", "/:owner/:name/settings/labels/:id", labelUpdate);
src/web/live.ts +1 −1
@@ -302,7 +302,7 @@ export const liveClientJs = /* js */ `
302302 'label.created', 'label.updated', 'label.deleted',
303303 'run.queued', 'run.started', 'run.finished', 'app.serving',
304304 'app.created', 'app.updated', 'app.deleted', 'app.stopped',
305 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
305 'repo.upstream_checked', 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
306306 ];
307307 var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean);
308308 var watching = [repo].concat(extra).filter(function (r, i, all) {
src/web/pages/repo.ts +2
@@ -8,6 +8,7 @@
88 * path segment and can never smuggle a path of its own.
99 */
1010
11 import { followingLine } from "./upstream-follow.ts";
1112 import { NOT_A_MEMBER } from "../../core/roles.ts";
1213 import { storageLimitMessage } from "../../core/storage.ts";
1314 import { canRead, listForks, type Repo } from "../../core/repos.ts";
@@ -652,6 +653,7 @@ function aboutCard(
652653 }),
653654 )}
654655 </ul>
656 ${followingLine(ctx, repo)}
655657 ${whereItLives}
656658 ${when(
657659 repo.visibility === "public" && repo.origin === null,
src/web/pages/settings.ts +3 −1
@@ -119,6 +119,7 @@ import { cutWaveFor } from "../../actions/cut_wave.ts";
119119 import { proposalTokensSection } from "./proposal-tokens.ts";
120120 import { secretsSection } from "./secrets.ts";
121121 import { checkMemoryAt } from "../../apps/check-memory.ts";
122 import { followUpstreamCard } from "./upstream-follow.ts";
122123 import { saveGrants } from "./subscriptions.ts";
123124
124125 type Section =
@@ -340,6 +341,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
340341 { head: html`${icon("branch")}<h2>Branch and gates</h2>` },
341342 );
342343 const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`);
344 const upstream = followUpstreamCard(ctx, repo);
343345 const wave = card(
344346 html`<form method="post" action="${base}/settings/wave">
345347 <p class="muted">
@@ -393,7 +395,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
393395 "Settings",
394396 html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail
395397 ? html`<div class="mt">${trail}</div>`
396 : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals
398 : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div>${proposals
397399 ? html`<div class="mt">${proposals}</div>`
398400 : null}${secrets
399401 ? html`<div class="mt">${secrets}</div>`
src/web/pages/upstream-follow.ts added +138
@@ -0,0 +1,138 @@
1 /**
2 * Following an upstream (#3442), on the page: the settings card that turns
3 * it on, points it and checks now; and the one line the repository's About
4 * card shows admins. Every mutation is a same-origin POST through
5 * `readForm`; the doors only word what `actions/upstream_follow.ts` answers.
6 */
7
8 import {
9 checkedWords,
10 checkUpstreamNow,
11 clockWords,
12 FOLLOW_REFUSALS,
13 followShown,
14 setFollowUpstream,
15 standing,
16 upstreamLabel,
17 } from "../../actions/upstream_follow.ts";
18 import { canAdmin, type Repo } from "../../core/repos.ts";
19 import { getFollow } from "../../core/upstream_follow.ts";
20 import type { Ctx } from "../../http/context.ts";
21 import { loadRepo, repoBase, repoNotFound } from "../chrome.ts";
22 import { field, readForm } from "../form.ts";
23 import { html, when, type Html } from "../html.ts";
24 import { icon } from "../icons.ts";
25 import { flashTo } from "../layout.ts";
26 import { btn, card, field as formField } from "../ui.ts";
27
28 /** HH:MM in UTC, with the instant for machines and the title for people. */
29 function clock(iso: string): Html {
30 const at = new Date(iso);
31 if (Number.isNaN(at.getTime())) return html``;
32 const exact = `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`;
33 return html`<time datetime="${at.toISOString()}" title="${exact}">${clockWords(iso)}</time>`;
34 }
35
36 /** The About card's line, for admins; null for everyone else. */
37 export function followingLine(ctx: Ctx, repo: Repo): Html | null {
38 const follow = followShown(ctx.app, repo, ctx.auth.user);
39 if (follow === undefined || !follow.enabled) return null;
40 return html`<p class="mt small muted" data-following-upstream>
41 ${icon("sync")} Following
42 <a href="${follow.url}" rel="noreferrer noopener">${upstreamLabel(follow.url)}</a>
43 <span class="mono">${follow.branch}</span>
44 ${follow.checkedAt === null ? null : html`· last checked ${clock(follow.checkedAt)}`}
45 · ${standing(follow)}
46 </p>`;
47 }
48
49 /** The settings card: on or off, the URL and branch, and "Check upstream now". */
50 export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
51 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null;
52 const follow = getFollow(ctx.app.db, repo.id);
53 const base = repoBase(repo);
54 return card(
55 html`<form method="post" action="${base}/settings/upstream">
56 <p class="muted">
57 New upstream commits arrive as a pull; never a push to
58 <span class="mono">${repo.defaultBranch}</span>. A check runs every 30 minutes.
59 </p>
60 ${formField({
61 label: "Upstream git URL",
62 name: "url",
63 value: follow?.url ?? "",
64 placeholder: "https://github.com/owner/name.git",
65 mono: true,
66 spellcheck: false,
67 hint: "A public upstream needs no credential. https or http only.",
68 })}
69 ${formField({
70 label: "Branch",
71 name: "branch",
72 value: follow?.branch ?? "main",
73 mono: true,
74 spellcheck: false,
75 })}
76 <div class="field">
77 <label class="check">
78 <input type="checkbox" name="enabled" ${follow?.enabled === true ? html`checked` : null} />
79 <span>Follow upstream</span>
80 </label>
81 </div>
82 ${when(
83 follow,
84 (f) => html`<p class="hint" data-following-standing>${standing(f)}${
85 f.checkedAt === null ? "" : html` · last checked ${clock(f.checkedAt)}`
86 }${f.error === "" ? "" : html` · ${f.error}`} · ${f.opened} proposed so far</p>`,
87 )}
88 <div class="form-actions">${btn("Save")}</div>
89 </form>
90 ${when(
91 follow?.enabled === true,
92 () => html`<form method="post" action="${base}/settings/upstream/check" class="mt">
93 ${btn("Check upstream now", { size: "sm", icon: "sync" })}
94 </form>`,
95 )}`,
96 { head: html`${icon("sync")}<h2 id="upstream">Follow upstream</h2>` },
97 );
98 }
99
100 // POST /:owner/:name/settings/upstream
101 export async function followUpstreamUpdate(ctx: Ctx): Promise<Response> {
102 const repo = loadRepo(ctx);
103 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return repoNotFound(ctx);
104 const form = await readForm(ctx, "admin");
105 if (form instanceof Response) return form;
106 const saved = setFollowUpstream(ctx.app, ctx.auth.user, repo, {
107 url: field(form, "url"),
108 branch: field(form, "branch"),
109 enabled: form.get("enabled") !== null,
110 });
111 return flashTo(
112 ctx,
113 `${repoBase(repo)}/settings#upstream`,
114 saved.ok
115 ? {
116 message: saved.value.enabled
117 ? `Following ${upstreamLabel(saved.value.url)} ${saved.value.branch}. The next check runs within a minute.`
118 : "Following the upstream is off. Nothing is checked or proposed.",
119 }
120 : { tone: "danger", message: FOLLOW_REFUSALS[saved.error] },
121 );
122 }
123
124 // POST /:owner/:name/settings/upstream/check
125 export async function followUpstreamCheck(ctx: Ctx): Promise<Response> {

No test reaches this handler. Add three cases: a guest gets 404, an evil origin gets 403, and an admin gets 303 with the check's flash.

126 const repo = loadRepo(ctx);
127 if (!repo || !canAdmin(ctx.app.db, repo, ctx.auth.user)) return repoNotFound(ctx);
128 const form = await readForm(ctx, "admin");
129 if (form instanceof Response) return form;
130 const checked = await checkUpstreamNow(ctx.app, ctx.auth.user, repo);
131 return flashTo(
132 ctx,
133 `${repoBase(repo)}/settings#upstream`,
134 checked.ok
135 ? { message: checkedWords(checked.value) }
136 : { tone: "danger", message: FOLLOW_REFUSALS[checked.error] },
137 );
138 }
test/upstream-follow-web.test.ts added +207
@@ -0,0 +1,207 @@
1 /**
2 * Following an upstream, at the doors (#3980): the API and the settings
3 * card are an admin's; everyone else gets 404 and sees nothing.
4 */
5 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { createApp, type App } from "../src/app.ts";
10 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
11 import { checkUpstream } from "../src/actions/upstream_follow.ts";
12 import { listEvents } from "../src/core/events.ts";
13 import { getRepo } from "../src/core/repos.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { serve, type Servers } from "../src/http/server.ts";
16
17 let dataDir = "";
18 let app: App;
19 let servers: Servers;
20 let base = "";
21 let alice = "";
22 let guest = "";
23 const URL_ = "https://example.com/up/stream.git";
24
25 type Body = { following: { url: string; branch: string; enabled: boolean } | null };
26
27 const signIn = async (username: string): Promise<string> =>
28 (
29 (
30 await fetch(`${base}/signin`, {
31 method: "POST",
32 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
33 redirect: "manual",
34 })
35 ).headers.get("set-cookie") ?? ""
36 ).split(";")[0]!;
37
38 const api = (cookie: string, method: string, path: string, json?: object, origin = base) =>
39 fetch(`${base}/api/v1/repos/alice/square/upstream${path}`, {
40 method,
41 headers: {
42 ...(cookie ? { cookie } : {}),
43 origin,
44 "content-type": "application/json",
45 },
46 ...(json ? { body: JSON.stringify(json) } : {}),
47 });
48
49 const read = async (cookie: string): Promise<Body> => (await api(cookie, "GET", "")).json() as Promise<Body>;
50
51 beforeAll(async () => {
52 dataDir = await mkdtemp(join(tmpdir(), "forge-follow-web-"));
53 app = createApp({ dataDir, port: 0 });
54 const make = async (username: string, extra: object = {}) => {
55 const made = await createUser(app.db, {
56 origin: "seed",
57 trusted: true,
58 username,
59 email: `${username}@forge.local`,
60 password: "a-good-passphrase",
61 ...extra,
62 });
63 if (!made.ok) throw new Error(JSON.stringify(made.error));
64 return made.value;
65 };
66 const owner = await make("alice", { adminIfFirst: true });
67 await make("guest");
68 const made = await createRepo(app, owner, { name: "square", visibility: "public" });
69 if (!made.ok) throw new Error(JSON.stringify(made.error));
70 const seeded = await commitToBranch(app, owner, made.value, {
71 message: "Start",
72 files: [{ path: "README.md", content: "# square\n" }],
73 });
74 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
75 servers = await serve(app);
76 base = servers.url;
77 alice = await signIn("alice");
78 guest = await signIn("guest");
79 });
80
81 afterAll(async () => {
82 servers?.stop();
83 app?.close();
84 await rm(dataDir, { recursive: true, force: true });
85 });
86
87 describe("the API", () => {
88 test("anonymous is 401, a stranger is 404, the admin reads null", async () => {
89 expect((await api("", "GET", "")).status).toBe(401);
90 expect((await api(guest, "GET", "")).status).toBe(404);
91 const read = await api(alice, "GET", "");
92 expect(read.status).toBe(200);
93 expect(await read.json()).toEqual({ following: null });
94 });
95
96 test("a stranger cannot set or check; a cross-origin set is refused", async () => {
97 const put = { url: URL_, branch: "main", enabled: true };
98 expect((await api(guest, "PUT", "", put)).status).toBe(404);
99 expect((await api(guest, "POST", "/check")).status).toBe(404);
100 expect((await api(alice, "PUT", "", put, "https://evil.example")).status).toBe(403);
101 expect((await read(alice))).toEqual({ following: null });
102 });
103
104 test("input is validated by upstreamUrl and the branch is checked", async () => {
105 for (const url of ["git://example.com/a.git", "ssh://[email protected]/a", "https://u:[email protected]/a", "file:///etc"]) {
106 const res = await api(alice, "PUT", "", { url, branch: "main", enabled: true });
107 expect(res.status).toBe(422);
108 expect(((await res.json()) as { error: string }).error).toBe("invalid_url");
109 }
110 const bad = await api(alice, "PUT", "", { url: URL_, branch: "-x", enabled: true });
111 expect(((await bad.json()) as { error: string }).error).toBe("invalid_branch");
112 expect((await api(alice, "PUT", "", { url: URL_ })).status).toBe(422);
113 });
114
115 test("an admin sets, reads, checks and turns it off", async () => {
116 const off = await api(alice, "POST", "/check");
117 expect([off.status, ((await off.json()) as { error: string }).error]).toEqual([422, "off"]);
118 const set = await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true });
119 expect(set.status).toBe(200);
120 expect(((await set.json()) as Body).following).toMatchObject({ url: URL_, branch: "main", enabled: true });
121 expect((await read(alice)).following?.enabled).toBe(true);
122 // Unreachable from here: the check fails and says why, it does not throw.
123 const checked = await api(alice, "POST", "/check");
124 expect([200, 422]).toContain(checked.status);
125 const cleared = await api(alice, "PUT", "", { url: "", branch: "", enabled: false });
126 expect(((await cleared.json()) as Body).following).toMatchObject({ url: URL_, enabled: false });
127 });
128 });
129
130 describe("the pages", () => {
131 const settings = async (cookie: string) =>
132 (await fetch(`${base}/alice/square/settings`, { headers: cookie ? { cookie } : {} })).text();
133 const about = async (cookie: string) =>
134 (await fetch(`${base}/alice/square`, { headers: cookie ? { cookie } : {} })).text();
135
136 test("the card renders for an admin and the About line shows only when on", async () => {
137 expect(await settings(alice)).toContain("Follow upstream");
138 expect(await about(alice)).not.toContain("data-following-upstream");
139 await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true });
140 expect(await settings(alice)).toContain("Check upstream now");
141 expect(await about(alice)).toContain("data-following-upstream");
142 expect(await about(alice)).toContain("example.com/up/stream");
143 });
144
145 test("a guest and a stranger see none of it", async () => {
146 expect(await about(guest)).not.toContain("data-following-upstream");
147 expect(await about("")).not.toContain("example.com/up/stream");
148 expect(await settings(guest)).not.toContain("Check upstream now");
149 });
150
151 test("the form posts are an admin's and same-origin", async () => {
152 const post = (cookie: string, origin: string) =>
153 fetch(`${base}/alice/square/settings/upstream`, {
154 method: "POST",
155 headers: { cookie, origin },
156 body: new URLSearchParams({ url: "https://example.com/other.git", branch: "dev", enabled: "on" }),
157 redirect: "manual",
158 });
159 expect((await post(guest, base)).status).toBe(404);
160 expect((await post(alice, "https://evil.example")).status).toBe(403);
161 expect((await post(alice, base)).status).toBe(303);
162 expect((await read(alice)).following?.branch).toBe("dev");
163 });
164
165 test("the check post is an admin's and same-origin, and says what it found", async () => {
166 const check = (cookie: string, origin: string) =>
167 fetch(`${base}/alice/square/settings/upstream/check`, {
168 method: "POST",
169 headers: { cookie, origin },
170 redirect: "manual",
171 });
172 const flashOf = (res: Response) => decodeURIComponent(res.headers.get("set-cookie") ?? "") + (res.headers.get("location") ?? "");
173 expect((await check(guest, base)).status).toBe(404);
174 expect((await check(alice, "https://evil.example")).status).toBe(403);
175 // On, but nothing reachable from here: the admin is told, not thrown at.
176 await api(alice, "PUT", "", { url: URL_, branch: "main", enabled: true });
177 const done = await check(alice, base);
178 expect(done.status).toBe(303);
179 expect(done.headers.get("location")).toContain("/alice/square/settings#upstream");
180 // Off: the same post answers that the check is off.
181 await api(alice, "PUT", "", { url: "", branch: "", enabled: false });
182 const off = await check(alice, base);
183 expect(off.status).toBe(303);
184 expect(flashOf(off)).toContain("Following an upstream is off");
185 });
186 });
187
188 describe("the check's event", () => {
189 const checked = () =>
190 listEvents(app.db, {}).filter((e) => e.kind === "repo.upstream_checked");
191
192 test("one event with an empty payload per check, and none when it is off", async () => {
193 const repo = getRepo(app.db, "alice", "square")!;
194 const put = (enabled: boolean) =>
195 api(alice, "PUT", "", { url: URL_, branch: "main", enabled });
196 await put(false);
197 const before = checked().length;
198 expect((await checkUpstream(app, repo)).ok).toBe(false);
199 expect(checked().length).toBe(before);
200 await put(true);
201 await checkUpstream(app, repo);
202 const after = checked();
203 expect(after.length).toBe(before + 1);
204 expect(after.at(-1)?.payload).toEqual({});
205 expect(after.at(-1)?.repoId).toBe(repo.id);
206 });
207 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs