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

Stop on an app's production or preview goes through the confirm page, which says the address goes quiet and the data stays #7993

Merged opened this

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

Merged

scout/forge:scout/7977-stop-on-an-app-s-production-or-preview-g → main 1 commit fork

  • Merged. scout/7977-stop-on-an-app-s-production-or-preview-g was merged into main as dc1fdd6.

Review

Sign in to review this change.
6 files changed +206 −11
src/web/pages/apps.ts +6 −1
@@ -783,7 +783,12 @@ async function envForm(
783783 * a form field; with no referer the fallback is the repository's home.
784784 */
785785 function envBack(ctx: Ctx, repo: Repo): string {
786 return safeBack(ctx) ?? repoBase(repo);
786 // Arriving from the confirm page, going "back" would ask again; the
787 // repository's page is where the person can see what changed.
788 const back = safeBack(ctx);
789 if (back === undefined || back.split("?")[0] === "/-/confirm")
790 return repoBase(repo);
791 return back;
787792 }
788793
789794 function envFailure(ctx: Ctx, repo: Repo, error: string): Response {
src/web/pages/search.ts +10
@@ -661,6 +661,16 @@ const DESTRUCTIVE: {
661661 phrase: "",
662662 }),
663663 },
664 {
665 pattern: /^\/([^/]+)\/([^/]+)\/app\/(production|pull-[1-9]\d*)\/stop$/,
666 describe: (m) => ({
667 verb: "Stop",
668 title: `Stop ${m[3]} of ${m[1]}/${m[2]}?`,
669 detail:
670 "It stops answering at its address until someone presses Redeploy. Its data stays where it is.",
671 phrase: "",
672 }),
673 },
664674 {
665675 pattern: /^\/settings\/crew\/([^/]+)\/delete$/,
666676 describe: (m) => ({
src/web/ui.ts +5 −3
@@ -609,9 +609,11 @@ export function envActions(
609609 ${when(
610610 env.state !== "stopped",
611611 () =>
612 html`<form method="post" action="${base}/app/${env.env}/stop">
613 ${btn("Stop", { size: "sm", icon: "square" })}
614 </form>`,
612 confirmAction({
613 action: `${base}/app/${env.env}/stop`,
614 verb: "Stop",
615 size: "sm",
616 }),
615617 )}`,
616618 );
617619 }
test/confirm-app-stop.test.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * Stopping production or a preview asks first (#7977): the head card's Stop
3 * is a link to the confirm page, the confirm page knows the route and words
4 * it, and its own Stop lands the person on the app, not back on the
5 * question they just answered.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { enableApp } from "../src/actions/apps.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { AppsRuntime } from "../src/apps/runtime.ts";
16 import { getAppByRepo } from "../src/core/apps.ts";
17 import type { Repo } from "../src/core/repos.ts";
18 import { createUser } from "../src/core/users.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { promote } from "./helpers/members.ts";
21
22 const PASSWORD = "a-good-passphrase";
23 const TO = "/alice/notes/app/production/stop";
24 const CONFIRM = `/-/confirm?to=${encodeURIComponent(TO)}`;
25
26 describe("Stop on an app's environment goes through the confirm page", () => {
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let base: string;
31 let repo: Repo;
32 let runtime: AppsRuntime;
33 let alice: string;
34 let bob: string;
35
36 const signIn = async (username: string): Promise<string> =>
37 (
38 (
39 await fetch(`${base}/signin`, {
40 method: "POST",
41 body: new URLSearchParams({ username, password: PASSWORD }),
42 redirect: "manual",
43 })
44 ).headers.get("set-cookie") ?? ""
45 ).split(";")[0]!;
46
47 const person = async (username: string, admin = false) => {
48 const made = await createUser(app.db, {
49 origin: "seed",
50 trusted: true,
51 username,
52 email: `${username}@forge.local`,
53 password: PASSWORD,
54 adminIfFirst: admin,
55 });
56 if (!made.ok) throw new Error(made.error);
57 promote(app.db, made.value);
58 return made.value;
59 };
60
61 /** Production serving, as the runtime holds it, with no real deploy. */
62 const putLive = (): void => {
63 const record = getAppByRepo(app.db, repo.id)!;
64 (
65 runtime as unknown as { envs: Map<string, Record<string, unknown>> }
66 ).envs.set(`${record.id}/production`, {
67 app: record,
68 env: "production",
69 chain: Promise.resolve(),
70 restarts: [],
71 live: {
72 commitOid: "a".repeat(40),
73 handle: { port: 4321, stop: async () => {} },
74 runId: "fake",
75 since: new Date().toISOString(),
76 manifest: { tty: null },
77 },
78 });
79 };
80
81 const get = (cookie: string, path: string): Promise<Response> =>
82 fetch(`${base}${path}`, {
83 headers: { cookie: `${cookie}; forge_mode=studio` },
84 redirect: "manual",
85 });
86
87 beforeAll(async () => {
88 dataDir = await mkdtemp(join(tmpdir(), "forge-confirm-app-stop-"));
89 app = createApp({ dataDir, port: 0 });
90 servers = await serve(app);
91 base = servers.url;
92 runtime = new AppsRuntime(app, {
93 domain: "apps.test",
94 scheme: "http",
95 publicPort: servers.port,
96 forgeUrl: servers.url,
97 concurrency: 1,
98 });
99 const a = await person("alice", true);
100 await person("bob");
101 alice = await signIn("alice");
102 bob = await signIn("bob");
103 const made = await createRepo(app, a, {
104 name: "notes",
105 visibility: "public",
106 description: "a place for notes",
107 });
108 if (!made.ok) throw new Error(String(made.error));
109 repo = made.value;
110 const seeded = await commitToBranch(app, a, repo, {
111 message: "Start",
112 files: [{ path: "README.md", content: "notes\n" }],
113 });
114 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
115 const enabled = await enableApp(app, a, repo, { slug: "notes" });
116 if (!enabled.ok) throw new Error(String(enabled.error));
117 });
118
119 afterAll(async () => {
120 await runtime.stop();
121 servers.stop();
122 app.close();
123 await rm(dataDir, { recursive: true, force: true });
124 });
125
126 test("the app page's Stop is a link to the confirm page; Redeploy stays a form", async () => {
127 putLive();
128 const html = await (await get(alice, "/alice/notes")).text();
129 expect(html).toContain(`href="${CONFIRM}"`);
130 expect(html).not.toContain(`action="${TO}"`);
131 expect(html).toContain('action="/alice/notes/app/production/redeploy"');
132 });
133
134 test("the confirm page words a Stop of production for a writer, and knows no made-up env", async () => {
135 const page = await get(alice, CONFIRM);
136 expect(page.status).toBe(200);
137 const html = (await page.text()).replace(/\s+/g, " ");
138 expect(html).toContain("Stop production of alice/notes?");
139 expect(html).toContain(
140 "It stops answering at its address until someone presses Redeploy. Its data stays where it is.",
141 );
142 expect(html).toContain(`action="${TO}"`);
143 const preview = await get(
144 alice,
145 `/-/confirm?to=${encodeURIComponent("/alice/notes/app/pull-3/stop")}`,
146 );
147 expect(preview.status).toBe(200);
148 expect(await preview.text()).toContain("Stop pull-3 of alice/notes?");
149 const bogus = await get(
150 alice,
151 `/-/confirm?to=${encodeURIComponent("/alice/notes/app/evil/stop")}`,
152 );
153 expect(bogus.status).toBe(404);
154 });
155
156 test("Stop from the confirm page lands on the app, not back on /-/confirm", async () => {
157 putLive();
158 const stopped = await fetch(`${base}${TO}`, {
159 method: "POST",
160 headers: {
161 cookie: `${alice}; forge_mode=studio`,
162 origin: base,
163 referer: `${base}${CONFIRM}`,
164 },
165 body: new URLSearchParams({}),
166 redirect: "manual",
167 });
168 expect(stopped.status).toBe(303);
169 expect(stopped.headers.get("location")).toBe("/alice/notes");
170 });
171
172 test("a reader is never offered Stop", async () => {
173 putLive();
174 const html = await (await get(bob, "/alice/notes")).text();
175 expect(html).toContain("serving");
176 expect(html).not.toContain(encodeURIComponent(TO));
177 });
178 });
test/studio-door.test.ts +3 −3
@@ -748,7 +748,7 @@ describe("the studio head says why the app is not up", () => {
748748 expect(mine).not.toContain("<install>");
749749 expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`);
750750 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
751 expect(mine).toContain('action="/alice/notes/app/production/stop"');
751 expect(mine).toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"');
752752 expect(mine).not.toContain('href="/alice/notes/app/production/open"');
753753 expect(mine).toContain('title="The last deploy failed"');
754754
@@ -757,7 +757,7 @@ describe("the studio head says why the app is not up", () => {
757757 expect(theirs).toContain("the deploy failed: &lt;install&gt; exited 1");
758758 expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`);
759759 expect(theirs).not.toContain("/app/production/redeploy");
760 expect(theirs).not.toContain("/app/production/stop");
760 expect(theirs).not.toContain("%2Fapp%2Fproduction%2Fstop");
761761 expect(theirs).not.toContain('href="/alice/notes/app/production/open"');
762762 });
763763
@@ -777,7 +777,7 @@ describe("the studio head says why the app is not up", () => {
777777 const mine = await head(writer);
778778 expect(mine).toContain("not running");
779779 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
780 expect(mine).not.toContain('action="/alice/notes/app/production/stop"');
780 expect(mine).not.toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"');
781781 expect(mine).not.toContain('href="/alice/notes/app/production/open"');
782782 expect(mine).toContain('title="Not running"');
783783 expect(await shelfBadge(writer)).toBe("not running");
test/studio-search.test.ts +4 −4
@@ -798,7 +798,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
798798 putLive();
799799 const serving = await studioPage();
800800 expect(serving).toContain("serving");
801 expect(serving).toContain('action="/alice/notes/app/production/stop"');
801 expect(serving).toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"');
802802 // The page a person stays on is the one that refreshes itself: its live
803803 // mount asks for the app's state on a deploy event.
804804 expect(serving).toContain('data-app="1"');
@@ -817,7 +817,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
817817 expect(html).toContain('id="app-head"');
818818 expect(html).toContain("not running");
819819 expect(html).toContain('action="/alice/notes/app/production/redeploy"');
820 expect(html).not.toContain('action="/alice/notes/app/production/stop"');
820 expect(html).not.toContain('href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2Fproduction%2Fstop"');
821821 });
822822
823823 test("pressed on a change's page, Stop comes back to that change", async () => {
@@ -931,7 +931,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
931931 });
932932 const owner = await studioPage();
933933 expect(owner).toContain(`action="/alice/notes/app/${env}/redeploy"`);
934 expect(owner).toContain(`action="/alice/notes/app/${env}/stop"`);
934 expect(owner).toContain(`href="/-/confirm?to=%2Falice%2Fnotes%2Fapp%2F${env}%2Fstop"`);
935935 expect(owner).toContain(`href="/alice/notes/app/${env}/open"`);
936936 expect(owner).toContain('href="/alice/notes/settings/app"');
937937
@@ -940,7 +940,7 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
940940 ).text();
941941 expect(stranger).toContain(`href="/alice/notes/app/${env}/open"`);
942942 expect(stranger).not.toContain("/redeploy");
943 expect(stranger).not.toContain("/stop");
943 expect(stranger).not.toContain("%2Fstop");
944944 expect(stranger).not.toContain('href="/alice/notes/settings/app"');
945945 });
946946 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs