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

Stop mirroring goes through the confirm page, which names the mirror and says nothing more is pushed #8005

Merged opened this

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

Merged

scout/forge:scout/7979-stop-mirroring-goes-through-the-confirm → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7979-stop-mirroring-goes-through-the-confirm was merged into main as 18e0f30.

Review

Sign in to review this change.
3 files changed +164 −6
src/web/pages/search.ts +22 −1
@@ -15,7 +15,8 @@ import { search } from "../../actions/search.ts";
1515 import { envsFor } from "../../apps/runtime.ts";
1616 import type { AppRecord } from "../../core/apps.ts";
1717 import { getPull } from "../../core/pulls.ts";
18 import { canWrite, enterableApps, getRepo } from "../../core/repos.ts";
18 import { canAdmin, canWrite, enterableApps, getRepo } from "../../core/repos.ts";
19 import { getMirrorTarget } from "../../core/migrate_mirror.ts";
1920 import {
2021 cleanQuery,
2122 CODE_BUDGET_MS,
@@ -577,6 +578,26 @@ const DESTRUCTIVE: {
577578 phrase: "",
578579 }),
579580 },
581 {
582 pattern: /^\/([^/]+)\/([^/]+)\/settings\/mirror\/stop$/,
583 describe: (m, ctx) => {
584 // The mirror's name is read fresh, and only for someone who could
outdated

Title names m[1]/m[2], the repo, not the mirror target. Show the mirror full name (octo/gizmo) via getMirrorTarget.

585 // stop it, so this page never says where a private repository goes.
outdated

"cannot be turned back on from the settings page" is unverified here; say where it is re-enabled or drop it.

586 const repo = getRepo(ctx.app.db, m[1]!, m[2]!);
587 const held =
588 repo && canAdmin(ctx.app.db, repo, ctx.auth.user)
589 ? getMirrorTarget(ctx.app.db, repo.id)
590 : undefined;
591 return {
592 verb: "Stop mirroring",
593 title: held
594 ? `Stop mirroring ${m[1]}/${m[2]} to ${held.fullName}?`
595 : `Stop mirroring ${m[1]}/${m[2]}?`,
596 detail: `${held ? held.fullName : "The copy on the other side"} stays as it is; nothing more is pushed to it. Only a new migration sets up a mirror again.`,
597 phrase: "",
598 };
599 },
600 },
580601 {
581602 pattern: /^\/([^/]+)\/([^/]+)\/settings\/prompts\/([^/]+)\/delete$/,
582603 describe: (m) => ({
src/web/pages/settings.ts +6 −5
@@ -383,15 +383,16 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
383383 held === undefined
384384 ? null
385385 : card(
386 html`<form method="post" action="${base}/settings/mirror/stop">
387 <p class="muted" data-mirror-line>${mirrorLine(held)}</p>
386 html`<p class="muted" data-mirror-line>${mirrorLine(held)}</p>
388387 <p class="muted small">
389388 Stopping leaves ${held.fullName} as it is; nothing more is pushed.
390389 </p>
391390 <div class="form-actions">
392 ${btn("Stop mirroring", { variant: "primary" })}
393 </div>
394 </form>`,
391 ${confirmAction({
392 action: `${base}/settings/mirror/stop`,
393 verb: "Stop mirroring",
394 })}
395 </div>`,
395396 { head: html`${icon("branch")}<h2>Mirror</h2>` },
396397 );
397398 const response = settingsPageResponse(
test/confirm-mirror-stop.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * Stop mirroring asks first (#7979): the settings page links the button to
3 * the confirm page, which names the mirror and says nothing more is pushed.
4 */
5
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { getMirrorTarget, setMirrorTarget } from "../src/core/migrate_mirror.ts";
12 import { createRepo, type Repo } from "../src/core/repos.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15
16 describe("stop mirroring goes through the confirm page", () => {
17 let dataDir: string;
18 let app: App;
19 let servers: Servers;
20 let cookie: string;
21 let bobCookie: string;
22 let gizmo: Repo;
23 const STOP = "/alice/gizmo/settings/mirror/stop";
24 const confirmOf = (to: string) => `/-/confirm?to=${encodeURIComponent(to)}`;
25
26 const get = (path: string, as = cookie) =>
27 fetch(`${servers.url}${path}`, { headers: { cookie: as }, redirect: "manual" });
28
29 const signIn = async (username: string): Promise<string> => {
30 const signin = await fetch(`${servers.url}/signin`, {
31 method: "POST",
32 body: new URLSearchParams({ username, password: "mirrors-are-one-way" }),
33 redirect: "manual",
34 });
35 return `${(signin.headers.get("set-cookie") ?? "").split(";")[0]!}; forge_mode=dev`;
36 };
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-confirm-mirror-stop-"));
40 app = createApp({ dataDir, port: 0 });
41 servers = await serve(app);
42 const made = await createUser(app.db, {
43 origin: "seed",
44 trusted: true,
45 adminIfFirst: true,
46 username: "alice",
47 email: "[email protected]",
48 password: "mirrors-are-one-way",
49 });
50 if (!made.ok) throw new Error(String(made.error));
51 const bob = await createUser(app.db, {
52 origin: "seed",
53 trusted: true,
54 username: "bob",
55 email: "[email protected]",
56 password: "mirrors-are-one-way",
57 });
58 if (!bob.ok) throw new Error(String(bob.error));
59 for (const [owner, name] of [
60 [made.value.id, "gizmo"],
61 [made.value.id, "plain"],
62 ] as const) {
63 const repo = await createRepo(app.db, app.reposRoot, {
64 ownerId: owner,
65 name,
66 visibility: "private",
67 });
68 if (!repo.ok) throw new Error(String(repo.error));
69 if (name === "gizmo") gizmo = repo.value;
70 }
71 app.db.run("PRAGMA foreign_keys = OFF");
72 setMirrorTarget(app.db, {
73 repoId: gizmo.id,
74 originId: "origin-none",
75 fullName: "octo/gizmo",
76 cloneUrl: "https://example.test/octo/gizmo.git",
77 tips: {},
78 });
79 cookie = await signIn("alice");
80 bobCookie = await signIn("bob");
81 });
82
outdated

Assert the mirror name (octo/gizmo) is on the confirm page, and cover the POST stopping it plus the not-mirrored case.

83 afterAll(async () => {
84 servers.stop();
85 app.close();
86 await rm(dataDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
87 });
88
89 test("the settings page links Stop mirroring to the confirm page, not a bare form", async () => {
90 const response = await get("/alice/gizmo/settings");
91 expect(response.status).toBe(200);
92 const page = await response.text();
93 expect(page).toContain("data-mirror-line");
94 expect(page).toContain("Stopping leaves octo/gizmo as it is");
95 expect(page).toContain(confirmOf(STOP));
96 expect(page).not.toContain(`action="${STOP}"`);
97 });
98
99 test("the confirm page names the mirror and says nothing more is pushed", async () => {
100 const response = await get(confirmOf(STOP));
101 expect(response.status).toBe(200);
102 const page = await response.text();
103 expect(page).toContain("Stop mirroring alice/gizmo to octo/gizmo?");
104 expect(page).toContain("octo/gizmo stays as it is; nothing more is pushed to it");
105 expect(page).toContain("Only a new migration sets up a mirror again");
106 });
107
108 test("someone who cannot administer the repository is not told where it mirrors", async () => {
109 const response = await get(confirmOf(STOP), bobCookie);
110 expect(response.status).toBe(200);
111 const page = await response.text();
112 expect(page).toContain("Stop mirroring alice/gizmo?");
113 expect(page).not.toContain("octo/gizmo");
114 });
115
116 test("a repository that is not mirrored gets the plain wording", async () => {
117 const response = await get(confirmOf("/alice/plain/settings/mirror/stop"));
118 expect(response.status).toBe(200);
119 const page = await response.text();
120 expect(page).toContain("Stop mirroring alice/plain?");
121 expect(page).toContain("The copy on the other side stays as it is");
122 expect(page).not.toContain("octo/gizmo");
123 });
124
125 test("confirming posts to the route, which still stops the mirror", async () => {
126 const response = await fetch(`${servers.url}${STOP}`, {
127 method: "POST",
128 headers: { cookie, origin: new URL(servers.url).origin },
129 body: new URLSearchParams(),
130 redirect: "manual",
131 });
132 expect(response.status).toBe(303);
133 expect(response.headers.get("location")).toBe("/alice/gizmo/settings");
134 expect(getMirrorTarget(app.db, gizmo.id)).toBeUndefined();
135 });
136 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs