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

The /feed composer is a plain textarea and its Post bar, not a boxed card #10019

Merged opened this

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

Merged

scout/forge:scout/10016-the-feed-composer-is-a-plain-textarea-an → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/10016-the-feed-composer-is-a-plain-textarea-an was merged into main as 10dfe2e.

Review

Sign in to review this change.
3 files changed +95 −5
src/web/studio/feed.ts +1 −3
@@ -258,8 +258,7 @@ export function composer(ctx: Ctx, user: User): Html {
258258 action: html`${btn("New app", { href: "/apps/new", variant: "primary" })}
259259 ${btn("Repositories", { href: `/${user.username}` })}`,
260260 })}`;
261 const form = html`<form id="composer" class="card" method="post" action="/tell">
262 <div class="card-body stack">
261 const form = html`<form id="composer" class="stack" method="post" action="/tell">
263262 <input type="hidden" name="back" value="/feed" />

The inner lines (hidden field, textarea, bar) keep the old wrapper's indentation. Dedent them one level now that the wrapper is gone.

264263 <textarea class="input" name="text" rows="2" maxlength="2000" required placeholder="Tell the crew what should change" aria-label="Tell the crew" data-draft="${user.id}">${sharedDraft(ctx.url.searchParams)}</textarea>
265264 <div class="bar">
@@ -272,7 +271,6 @@ export function composer(ctx: Ctx, user: User): Html {
272271 <span class="spacer"></span>
273272 ${btn("Post", { variant: "primary" })}
274273 </div>
275 </div>
276274 </form>`;
277275 return repos.length === 0 ? html`${form}${noApps}` : form;
278276 }
test/feed-composer-language.test.ts added +92
@@ -0,0 +1,92 @@
1 /** The /feed composer speaks the home page's language (#10016): one field and one bar, no card. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { createSession } from "../src/core/sessions.ts";
10 import { createUser } from "../src/core/users.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { promote } from "./helpers/members.ts";
13
14 let dir = "";
15 let app: App;
16 let servers: Servers;
17 let cookie = "";
18 let bare = "";
19
20 async function user(username: string, first = false) {
21 const r = await createUser(app.db, {
22 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
23 password: "a-good-passphrase", adminIfFirst: first,
24 });
25 if (!r.ok) throw new Error(r.error);
26 promote(app.db, r.value);
27 return r.value;
28 }
29
30 beforeAll(async () => {
31 dir = await mkdtemp(join(tmpdir(), "forge-feed-composer-"));
32 app = createApp({ dataDir: dir, port: 0 });
33 servers = await serve(app);
34 await user("keeper", true);
35 const alice = await user("alice");
36 const carol = await user("carol");
37 const repo = await createRepo(app, alice, { name: "garden", visibility: "public" });
38 if (!repo.ok) throw new Error("repo");
39 insertApp(app.db, { repoId: repo.value.id, slug: "garden", access: "public" });
40 cookie = `forge_session=${createSession(app.db, alice.id).token}; forge_mode=studio`;
41 bare = `forge_session=${createSession(app.db, carol.id).token}; forge_mode=studio`;
42 });
43 afterAll(async () => {
44 servers.stop();
45 app.close();
46 await rm(dir, { recursive: true, force: true });
47 });
48
49 const composerOf = async (c: string): Promise<string> => {
50 const text = await (await fetch(`${servers.url}/feed`, { headers: { cookie: c } })).text();
51 const at = text.indexOf('<form id="composer"');
52 expect(at).toBeGreaterThan(-1);
53 return text.slice(at, text.indexOf("</form>", at));
54 };
55
56 test("the composer is one field and one bar, with no card", async () => {
57 const form = await composerOf(cookie);
58 const open = form.slice(0, form.indexOf(">") + 1);
59 expect(open).not.toContain("card");
60 expect(form).not.toContain("card-body");
61 expect(open).toContain('action="/tell"');
62 expect(form).toContain('name="back" value="/feed"');
63 expect(form).toContain("data-draft=");
64 expect(form).toContain('<div class="bar">');
65 expect(form).toMatch(/>\s*Post\s*</);
66 });
67
68 test("the select lists the viewer's repositories, escaped, and the forge", async () => {
69 const form = await composerOf(cookie);
70 expect(form).toContain('<option value="alice/garden">alice/garden</option>');
71 expect(form).toContain('<option value="forge">the forge</option>');
72 });
73
outdated

This does not exercise escaping: alice/garden has nothing to escape, so the assertion can never fail. Use a slug containing markup (stub the repo list, or call the option template directly), so the test fails if option values stop being escaped. Box 3 asks for exactly this.

74 test("a repository name with markup in it is escaped in the select, never raw", async () => {
75 // Names are validated at creation, so the hostile one is written straight to the row:
76 // the page must still escape what it prints.
77 const alice = app.db.query<{ id: string }, []>("SELECT id FROM user WHERE username = 'alice'").get()!;
78 const made = await createRepo(app, { id: alice.id } as never, { name: "wild", visibility: "public" });
79 if (!made.ok) throw new Error("repo");
80 insertApp(app.db, { repoId: made.value.id, slug: "wild", access: "public" });
81 app.db.query("UPDATE repo SET name = ?1 WHERE id = ?2").run('x"><img src=x onerror=alert(1)>', made.value.id);
82 const form = await composerOf(cookie);
83 expect(form).not.toContain("<img");
84 expect(form).not.toContain('x">');
85 expect(form).toContain("alice/x&quot;&gt;&lt;img src=x onerror=alert(1)&gt;");
86 });
87
88 test("with no repositories, the forge is the hidden target", async () => {
89 const form = await composerOf(bare);
90 expect(form).not.toContain("<select");
91 expect(form).toContain('name="app" value="forge"');
92 });
test/home-feed.test.ts +2 −2
@@ -124,9 +124,9 @@ describe("home is the feed", () => {
124124 await rm(dataDir, { recursive: true, force: true });
125125 });
126126
127 test("the composer is a §5 card, and no .composer class remains", async () => {
127 test("the composer is a plain form (no card), and no .composer class remains", async () => {
128128 const text = await (await get("/feed", aliceCookie)).text();
129 expect(text).toMatch(/<form id="composer" class="card" method="post" action="\/tell">\s*<div class="card-body/);
129 expect(text).toMatch(/<form id="composer" class="stack" method="post" action="\/tell">/);
130130 expect(text).not.toContain('class="composer');
131131 });
132132

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs