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

The app's owner hides a note or bans its sender from a screen, not only through `POST /__forge/note/moderate` #5100

Merged opened this

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

Merged

scout/forge:scout/4819-the-app-s-owner-hides-a-note-or-bans-its → main 4 commits fork 7 threads · 7 unresolved

  • Merged. scout/4819-the-app-s-owner-hides-a-note-or-bans-its was merged into main as ed7bc94.

Review

Sign in to review this change.
9 files changed +330 −7
README.md +7 −3
@@ -1004,7 +1004,8 @@ Under the box, four starter sentences are plain links (no script):
10041004 From the terminal, a repository screen that is an app has two palette rows
10051005 (`:`): *Tell the builders* sends a note as you, and *Notes from the app*
10061006 lists yours with their status; the repository's admin sees every sender's and
1007 may hide a note or ban its sender. The gating is the page's: a guest or a
1007 may hide a note, ban its sender, list the banned senders (*Banned senders*)
1008 and unban one. The gating is the page's: a guest or a
10081009 non-reader sees nothing, and a repository that is not an app has no rows.
10091010 Who may write: anyone on a public app, but a private app, and any sealed
10101011 pull's preview, take notes only from a person the app admits (the condition
@@ -1013,8 +1014,11 @@ Under the box, four starter sentences are plain links (no script):
10131014 happens on the forge, not on the app: the app host answers only
10141015 `POST /__forge/note` and `GET /__forge/notes`, and `/__forge/note/moderate` is
10151016 a 404 there, because the app's own code (or a preview's) could call it
1016 same-origin with a visiting admin's cookie. The terminal screen above is the
1017 owner's tool today.
1017 same-origin with a visiting admin's cookie. The terminal screen above and the
outdated

This says the terminal screen is one of the owner's tools, but the terminal can't unban or list banned senders yet. Add those there, then say so in the terminal paragraph above ("may hide a note or ban its sender").

1018 app's studio page are the owner's tools: the page lists the latest notes
1019 (sender, words, page, when) for those who may admin the repository, each with
1020 Hide and Ban sender, and every banned sender once with Unban, posting to
1021 `POST /:owner/:name/app/notes` on the forge's origin (`web/studio/notes.ts`).
10181022 Limits count the socket's client (`http/peer.ts`, with its trusted-proxy
10191023 rule), never a header the client wrote, and all three are checked before any
10201024 is spent. The request body is read only up to 16 KB. The issue says its text
src/actions/app_notes.ts +49 −1
@@ -6,7 +6,7 @@
66 * both go through here, so the limits and the gating are one set.
77 */
88 import type { App } from "../app.ts";
9 import { banVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts";
9 import { banVisitor, bannedNotes, unbanVisitor, markSender, getNote, hideNote, insertNote, isBanned, notesOf, notesOfApp, type AppNote } from "../core/app_notes.ts";
1010 import type { AppRecord } from "../core/apps.ts";
1111 import { createLabel, getIssue, getLabel } from "../core/issues.ts";
1212 import { RateLimiter } from "../core/ratelimit.ts";
@@ -259,3 +259,51 @@ export async function moderateNote(
259259 if (action === "ban") banVisitor(app.db, record.id, note.visitor);
260260 return ok(true);
261261 }
262
263 /** What the owner's list shows of one note: the sender, the words, the page. */
264 export interface OwnerNote {
265 readonly id: string;
266 readonly by: string;
267 readonly person: boolean;
268 readonly text: string;
269 readonly page: string;
270 readonly at: string;
271 readonly banned: boolean;
272 }
273
274 /** The page a note was sent from, read back from the fenced line the issue holds. */
275 function pageOf(body: string): string {
276 const m = /^- Page: `([^`\n]*)`/m.exec(body);
277 return m?.[1] ?? "";
278 }
279
280 function ownerView(app: App, repo: Repo, n: AppNote, banned: boolean): OwnerNote | undefined {
281 const issue = getIssue(app.db, repo.id, n.issueNumber);
282 if (issue === undefined) return undefined;
283 return {
284 id: n.id,
285 by: n.name,
286 person: n.visitor.startsWith("u:"),
287 text: issue.title.startsWith(FROM_APP_TITLE) ? issue.title.slice(FROM_APP_TITLE.length) : issue.title,
288 page: pageOf(issue.body),
289 at: n.createdAt,
290 banned,
291 };
292 }
293

Low: the doc says "Writers only", but the gate is canAdmin. Say "Admins only" so the next reader doesn't loosen it to canWrite to match the comment.

294 /** The owner's screen: the latest notes still shown, and each banned sender once. Writers only. */
295 export function ownerNotes(app: App, record: AppRecord, repo: Repo, actor: User | undefined): { notes: OwnerNote[]; banned: OwnerNote[] } | undefined {

High (test gap): this canAdmin gate is the only thing keeping other senders' notes away from signed-in readers, and no test uses a signed-in non-admin. Add one: bob, a reader, GETs the studio page and gets no card.

296 if (actor === undefined || !canAdmin(app.db, repo, actor)) return undefined;
297 const pick = (rows: AppNote[], banned: boolean) =>
298 rows.map((n) => ownerView(app, repo, n, banned)).filter((n): n is OwnerNote => n !== undefined);
299 return { notes: pick(notesOfApp(app.db, record.id), false), banned: pick(bannedNotes(app.db, record.id), true) };
300 }
301
302 /** The owner's: let the sender of a note write again. */
303 export function unbanSender(app: App, record: AppRecord, repo: Repo, actor: User | undefined, noteId: string): Result<true, "forbidden" | "not_found"> {

Only the web page calls unbanSender. src/tui/screens/notes.ts needs a matching Unban action and a banned-senders list for the admin, so both doors can do the same things.

304 if (actor === undefined || !canAdmin(app.db, repo, actor)) return err("forbidden");

High (test gap): nothing would fail if this check went. Add a web POST of action=unban as a signed-in non-admin: it should land on the forbidden flash and leave the ban in place.

305 const note = getNote(app.db, record.id, noteId);
306 if (note === undefined) return err("not_found");
307 unbanVisitor(app.db, record.id, note.visitor);
308 return ok(true);
309 }
src/core/app_notes.ts +23
@@ -100,6 +100,29 @@ export function banVisitor(db: Database, appId: string, visitor: string): void {
100100 db.query("UPDATE app_note SET hidden = 1 WHERE app_id = ?1 AND visitor = ?2").run(appId, visitor);
101101 }
102102
103 /** Let a banned sender write again; the notes hidden with the ban stay hidden. */
104 export function unbanVisitor(db: Database, appId: string, visitor: string): void {
105 db.query("DELETE FROM app_note_ban WHERE app_id = ?1 AND visitor = ?2").run(appId, visitor);
106 }
107
108 /**
109 * Each banned sender once, newest ban first, as their latest note (hidden or
110 * not): the note's id is how the owner names them, never the visitor key.
111 */
112 export function bannedNotes(db: Database, appId: string, limit = 50): AppNote[] {
113 return db
114 .query<Row, [string, number]>(
115 `SELECT n.* FROM app_note_ban b
116 JOIN app_note n ON n.app_id = b.app_id AND n.visitor = b.visitor
117 WHERE b.app_id = ?1
118 AND n.id = (SELECT m.id FROM app_note m WHERE m.app_id = b.app_id AND m.visitor = b.visitor
119 ORDER BY m.created_at DESC, m.id DESC LIMIT 1)
120 ORDER BY b.created_at DESC, n.id DESC LIMIT ?2`,
outdated

Medium: there's no LIMIT, so the studio page renders every banned sender on each admin view, with a correlated subquery and a getIssue per row. Add LIMIT ?2 (50) like notesOfApp.

121 )
122 .all(appId, limit)
123 .map(fromRow);
124 }
125
103126 export function isBanned(db: Database, appId: string, visitor: string): boolean {
104127 return (
105128 db
src/http/server.ts +2
@@ -226,6 +226,7 @@ import {
226226 newIssuePage,
227227 newIssueSubmit,
228228 } from "../web/pages/issues.ts";
229 import { noteModerate } from "../web/studio/notes.ts";
229230 import { tellPage } from "../web/studio/tell.ts";
230231 import { tellHerePage } from "../web/studio/tell_here.ts";
231232 import { globalMapPage, mapPage } from "../web/pages/map.ts";
@@ -1234,6 +1235,7 @@ export function buildRouter(): Router<Handler> {
12341235 router.add("GET", "/:owner/:name/app/:env/open", appOpen);
12351236 // Where a private app sends a person who arrived by its link.
12361237 router.add("GET", "/apps/open", appsOpen);
1238 router.add("POST", "/:owner/:name/app/notes", noteModerate);
12371239 router.add("POST", "/:owner/:name/app/:env/redeploy", appRedeploy);
12381240 router.add("POST", "/:owner/:name/app/:env/stop", appStop);
12391241 router.add("GET", "/:owner/:name/runs", runsList);
src/tui/screens/notes.ts +37 −2
@@ -3,11 +3,11 @@
33 * the terminal's door to what the button does on an app's page, with the
44 * page's gating. A signed-in person who reads the repository may send a note
55 * and see their own with its status; the repository's admin sees every
6 * sender's and may hide a note or ban its sender. A repository that is not
6 * sender's and may hide a note or ban its sender, see the banned senders and unban them. A repository that is not

Low: this header line runs well past the width of the block around it. Rewrap it to match the surrounding comment.

77 * an app, or a guest, gets no rows.
88 */
99
10 import { moderateNote, notesFor, sendNote, type Sender } from "../../actions/app_notes.ts";
10 import { moderateNote, notesFor, ownerNotes, sendNote, unbanSender, type Sender } from "../../actions/app_notes.ts";
1111 import { getAppByRepo } from "../../core/apps.ts";
1212 import { canAdmin, canEnterApp, canRead, type Repo } from "../../core/repos.ts";
1313 import type { Ctx, Screen } from "../app/app.ts";
@@ -24,6 +24,40 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R
2424 const name = `${repo.ownerName}/${repo.name}`;
2525 const sender: Sender = { key: `u:${user.id}`, name: `@${user.username}`, person: true };
2626 const admin = canAdmin(f.app.db, repo, user);
27 const unban: Command[] = [];
28 if (admin) {
29 unban.push({
30 id: "banned",
31 title: `${name} — Banned senders`,
32 hint: "unban a sender",
33 run: () => {
34 const banned = ownerNotes(f.app, record, repo, user)?.banned ?? [];
35 if (banned.length === 0) return ctx.toast("No one is banned.");
36 ctx.push(
37 pickerScreen(ctx, parent(), {
38 title: "Banned senders",
39 items: banned.map((n) => ({ id: n.id, label: n.by, hint: n.text })),
40 onPick: ([id]) => {
41 const picked = banned.find((n) => n.id === id);
42 if (picked === undefined) return;
43 ctx.push(
44 confirmScreen(ctx, parent(), {
45 title: "Unban this sender",
46 body: [picked.by, picked.text],
47 actions: [{ label: "Keep banned", variant: "ghost" }, { label: "Unban" }],
48 onChoose: (i) => {
49 if (i < 1) return;
50 const done = unbanSender(f.app, record, repo, user, picked.id);
51 ctx.toast(done.ok ? "Sender unbanned." : "That could not be done.", done.ok ? "success" : "danger");
52 },
53 }),
54 );
55 },
56 }),
57 );
58 },
59 });
60 }
2761 return [
2862 {
2963 id: "note",
@@ -77,5 +111,6 @@ export function noteCommands(f: Session, ctx: Ctx, parent: () => Screen, repo: R
77111 );
78112 },
79113 },
114 ...unban,
80115 ];
81116 }
src/web/studio/app.ts +2
@@ -51,6 +51,7 @@ import { changesHref, tellsHref } from "./nouns.ts";
5151 import { answersRegion } from "./answers.ts";
5252 import { nowCard, nowRegion } from "./now.ts";
5353 import { repoAsks } from "./asks.ts";
54 import { notesCard } from "./notes.ts";
5455 import { appPanels } from "./previews.ts";
5556 import { noCrewLine, servingHistory, type WordCtx } from "./words.ts";
5657 import { crewKeyNote } from "./crew.ts";
@@ -500,6 +501,7 @@ export async function studioApp(
500501 ${ownerFacts && ctx.auth.user ? answersRegion(ctx, repo, ctx.auth.user) : null}
501502 <div id="app-previews">${previews}</div>
502503 ${nowRegion(repo, now)}
504 ${notesCard(ctx, repo, record)}
503505 </div>
504506 <aside class="stack">
505507 ${crewCard} ${weekCard} ${ownCard} ${more}
src/web/studio/notes.ts added +88
@@ -0,0 +1,88 @@
1 /**
2 * The app's notes from its forge button (#4758), on the studio page, for the
3 * people who may moderate: the latest `from-app` notes with Hide and Ban
4 * sender, and each banned sender once with Unban. The buttons post to the
5 * forge's own origin (`POST /:owner/:name/app/notes`), never to the app's
6 * host, where moderation is a 404.
7 */
8 import { moderateNote, ownerNotes, unbanSender, type OwnerNote } from "../../actions/app_notes.ts";
9 import { getAppByRepo, type AppRecord } from "../../core/apps.ts";
10 import type { Repo } from "../../core/repos.ts";
11 import type { User } from "../../core/users.ts";
12 import type { Ctx } from "../../http/context.ts";
13 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
14 import { readForm } from "../form.ts";
15 import { html, type Html } from "../html.ts";
16 import { flashTo } from "../layout.ts";
17 import { badge, btn, icon, listCard, row, time } from "../ui.ts";
18 import { safeBack } from "../../http/context.ts";
19
20 const act = (base: string, n: OwnerNote, action: "hide" | "ban" | "unban", label: string, danger = false): Html =>
21 html`<form method="post" action="${base}/app/notes" class="inline">
22 <input type="hidden" name="id" value="${n.id}" />
23 <input type="hidden" name="action" value="${action}" />
24 ${btn(label, { size: "sm", ...(danger ? { variant: "danger" as const } : {}) })}
25 </form>`;
26
27 const sender = (n: OwnerNote): Html =>
28 html`${n.by} ${badge(n.person ? "signed in" : "anonymous", { variant: "outline" })}`;
29
30 /** The card, or nothing for anyone who may not moderate or when there is nothing to show. */
31 export function notesCard(ctx: Ctx, repo: Repo, record: AppRecord): Html | null {
32 const seen = ownerNotes(ctx.app, record, repo, ctx.auth.user);
33 if (seen === undefined || (seen.notes.length === 0 && seen.banned.length === 0)) return null;
34 const base = repoBase(repo);
35 const notes = seen.notes.map((n) =>
36 row({
37 title: n.text,
38 meta: html`${sender(n)} · ${n.page === "" ? "no page" : n.page} · ${time(n.at)}`,
39 trail: html`<span class="bar">${act(base, n, "hide", "Hide")} ${act(base, n, "ban", "Ban sender", true)}</span>`,
40 }),
41 );
42 const banned = seen.banned.map((n) =>
43 row({
44 title: sender(n),
45 meta: html`banned · last wrote ${time(n.at)}`,
46 trail: act(base, n, "unban", "Unban"),
47 }),
48 );
49 return html`<div class="stack" id="app-notes">
50 ${listCard(notes.length > 0 ? notes : [row({ title: html`<span class="muted">No notes shown.</span>` })], {
51 head: html`${icon("comment")}<h2>Notes from the app</h2>`,
52 })}
53 ${banned.length === 0 ? null : listCard(banned, { head: html`<h2>Banned senders</h2>` })}
54 </div>`;
55 }
56
57 const FAILURE: Record<string, string> = {
58 forbidden: "You need admin access to do that.",
59 unavailable: "That could not be done just now.",
60 };
61
62 // POST /:owner/:name/app/notes — hide a note, ban or unban its sender.
63 export async function noteModerate(ctx: Ctx): Promise<Response> {
64 const repo = loadRepo(ctx);
65 if (!repo) return notFoundPage(ctx);
66 const record = getAppByRepo(ctx.app.db, repo.id);
67 if (record === undefined) return notFoundPage(ctx);
68 const user: User | undefined = ctx.auth.user;
69 if (!user) return signInFirst(ctx);
70 const form = await readForm(ctx, "rw");
71 if (form instanceof Response) return form;
72 const id = String(form.get("id") ?? "");
73 const action = String(form.get("action") ?? "");
74 if (id === "" || id.length > 64 || !["hide", "ban", "unban"].includes(action))
75 return new Response("bad request", { status: 400 });
76 const done =
77 action === "unban"
78 ? unbanSender(ctx.app, record, repo, user, id)
79 : await moderateNote(ctx.app, record, repo, user, id, action as "hide" | "ban");
80 const back = safeBack(ctx) ?? repoBase(repo);
81 if (!done.ok) {
82 if (done.error === "not_found") return notFoundPage(ctx);
83 return flashTo(ctx, back, { tone: "danger", message: FAILURE[done.error] ?? "That could not be done." });
84 }
85 return flashTo(ctx, back, {
86 message: action === "hide" ? "Note hidden." : action === "ban" ? "Sender banned." : "Sender unbanned.",
87 });
88 }
test/apps-feedback.test.ts +106 −1
@@ -15,7 +15,7 @@ import { toolbarJs } from "../src/apps/toolbar.ts";
1515 import { filtered, moderateNote, noteClock, PER_APP_DAY, PER_SENDER, PER_SOURCE, saysFromApp } from "../src/actions/app_notes.ts";
1616 import { feedback, REQUEST_MAX } from "../src/apps/feedback.ts";
1717 import { getAppBySlug } from "../src/core/apps.ts";
18 import { getNote } from "../src/core/app_notes.ts";
18 import { getNote, isBanned } from "../src/core/app_notes.ts";
1919 import { grantedCookie } from "./helpers/identity.ts";
2020 import { getIssue, labelsFor, setIssueState } from "../src/core/issues.ts";
2121 import { getRepo } from "../src/core/repos.ts";
@@ -312,6 +312,111 @@ describe("the forge button takes notes from anyone", () => {
312312 expect((await at("diary", "/__forge/notes")).status).toBe(404);
313313 });
314314
315 test("the studio page lists the notes; a writer hides one, bans a sender and unbans them; a stranger sees and can do nothing", async () => {
316 // The minute's bounds are spent by the tests before; this one starts a later minute.
317 const start = Date.now() + 120_000;
318 noteClock.now = () => start;
319 const signin = await fetch(`${servers.url}/signin`, {
320 method: "POST",
321 headers: { origin: servers.url },
322 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
323 redirect: "manual",
324 });
325 const me = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
326 const forge = (path: string, init: RequestInit = {}, cookie = me) =>
327 fetch(`${servers.url}${path}`, {
328 ...init,
329 headers: { origin: servers.url, ...(cookie ? { cookie } : {}), ...(init.headers as Record<string, string>) },
330 redirect: "manual",
331 });
332 const form = (body: Record<string, string>, cookie = me) =>
333 forge("/alice/notes/app/notes", { method: "POST", body: new URLSearchParams(body) }, cookie);
334 const first = await post("studio words one", "", "10.7.0.1");
335 const cookie = cookieOf(first);
336 const sent = (await first.json()) as { note: { id: string; url: string }; name: string };
337 const number = Number(sent.note.url.split("/").pop());
338 const repo = getRepo(app.db, "alice", "notes")!;
339
340 const page = await (await forge("/alice/notes")).text();
341 expect(page).toContain("studio words one");
342 expect(page).toContain(sent.name);
343 expect(page).toContain("anonymous");
344 expect(page).toContain("/lists");
345 expect(page).toContain("Hide");
346 expect(page).toContain("Ban sender");
347 // a stranger reads no list and the post is refused
348 const stranger = await (await forge("/alice/notes", {}, "")).text();
349 expect(stranger).not.toContain("Ban sender");
350 expect(stranger).not.toContain('id="app-notes"');
351 const refused = await form({ id: sent.note.id, action: "hide" }, "");
352 expect(refused.status).toBe(303);
353 expect(refused.headers.get("location") ?? "").toContain("/signin");
354 expect(getIssue(app.db, repo.id, number)!.state).toBe("open");
355 // same-origin is checked
356 const cross = await forge("/alice/notes/app/notes", { method: "POST", headers: { origin: "http://evil.example" }, body: new URLSearchParams({ id: sent.note.id, action: "hide" }) });
357 expect(cross.status).toBe(403);
358 expect((await form({ id: sent.note.id, action: "nuke" })).status).toBe(400);
359
360 // hide: the issue closes and the row goes
361 expect((await form({ id: sent.note.id, action: "hide" })).status).toBe(303);
362 expect(getIssue(app.db, repo.id, number)!.state).toBe("closed");
363 expect(await (await forge("/alice/notes")).text()).not.toContain(`value="${sent.note.id}"`);
364
365 // ban: the sender's next note is refused and they are listed once with Unban
366 const second = await post("studio words two", cookie, "10.7.0.1");
367 expect(second.status).toBe(201);
368 const secondId = ((await second.json()) as { note: { id: string } }).note.id;
369 expect((await form({ id: secondId, action: "ban" })).status).toBe(303);
370 expect((await post("again", cookie, "10.7.0.1")).status).toBe(403);
371 const banned = await (await forge("/alice/notes")).text();
372 expect(banned).toContain("Banned senders");
373 expect(banned.split(`value="${secondId}"`).length - 1).toBe(1);
374 // a signed-in reader who is not an admin sees no card and can do nothing
375 const bobMade = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
376 if (!bobMade.ok) throw new Error(bobMade.error);
377 const bobIn = await fetch(`${servers.url}/signin`, {
378 method: "POST",
379 headers: { origin: servers.url },
380 body: new URLSearchParams({ username: "bob", password: "a-good-passphrase" }),
381 redirect: "manual",
382 });
383 const bob = (bobIn.headers.get("set-cookie") ?? "").split(";")[0]!;
384 expect(bob).not.toBe("");
385 const record = getAppBySlug(app.db, "notes")!;
386 const bobPage = await (await forge("/alice/notes", {}, bob)).text();
387 expect(bobPage).not.toContain('id="app-notes"');
388 expect(bobPage).not.toContain("studio words two");
389 expect(bobPage).not.toContain(sent.name);
390 const bannedBefore = isBanned(app.db, record.id, getNote(app.db, record.id, secondId)!.visitor);
391 expect(bannedBefore).toBe(true);
392 const third = await post("studio words three", "", "10.7.0.9");
393 const thirdNote = ((await third.json()) as { note: { id: string; url: string } }).note;
394 const thirdId = thirdNote.id;
395 const thirdNumber = Number(thirdNote.url.split("/").pop());
396 for (const action of ["hide", "ban", "unban"]) {
397 for (const id of [action === "unban" ? secondId : thirdId]) {
398 const refused = await form({ id, action }, bob);
399 expect(refused.status).toBe(303);
400 const flashed = await forge("/alice/notes", {}, `${bob}; ${(refused.headers.get("set-cookie") ?? "").split(";")[0]}`);
401 expect(await flashed.text()).toContain("You need admin access to do that.");
402 }
403 }
404 expect(getIssue(app.db, repo.id, thirdNumber)!.state).toBe("open");
405 expect(isBanned(app.db, record.id, getNote(app.db, record.id, thirdId)!.visitor)).toBe(false);
406 expect(isBanned(app.db, record.id, getNote(app.db, record.id, secondId)!.visitor)).toBe(true);
407 // unban
408 expect((await form({ id: secondId, action: "unban" })).status).toBe(303);
409 expect((await post("back again", cookie, "10.7.0.1")).status).toBe(201);
410 expect(await (await forge("/alice/notes")).text()).not.toContain(`value="${secondId}"`);
411 // the app host answers 404 for moderation, and for the forge's own path too
412 for (const path of ["/__forge/note/moderate", "/app/notes"])
413 expect((await at("notes", path, { method: "POST", body: JSON.stringify({ id: secondId, action: "ban" }) }, "")).status).toBe(404);
414 });
415
416 test("the clock is the real one again", () => {
417 noteClock.now = () => Date.now();
418 });
419
315420 test("the panel fits a phone and a desktop, and says no forge words first", () => {
316421 // 390x844: at most 320px wide within the viewport, and 70% high leaves the top (camera) clear.
317422 expect(toolbarJs).toContain("max-width:min(320px,calc(100vw - 64px))");
test/tui-notes.test.ts +16
@@ -11,6 +11,7 @@ import { join } from "node:path";
1111 import { newApp } from "../src/actions/apps.ts";
1212 import { createRepo } from "../src/actions/repos.ts";
1313 import { createApp, type App } from "../src/app.ts";
14 import { sendNote, type Sender } from "../src/actions/app_notes.ts";
1415 import { isBanned } from "../src/core/app_notes.ts";
1516 import { getAppBySlug } from "../src/core/apps.ts";
1617 import { getIssue, labelsFor } from "../src/core/issues.ts";
@@ -106,4 +107,19 @@ describe("notes from the app, in the terminal", () => {
106107 const record = getAppBySlug(app.db, "notes")!;
107108 expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(true);
108109 });
110
111 test("the admin lists the banned senders and unbans one; a reader has no such row", async () => {
112 const record = getAppBySlug(app.db, "notes")!;
113 expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(true);
114 expect(await palette(bob, notes, [":"])).not.toContain("Banned senders");
115 const listed = await palette(alice, notes, [":", "Banned", "\r"]);
116 expect(listed).toContain("@bob");
117 expect(await palette(bob, notes, [":", "Banned", "\r"])).not.toContain("Unban");
118 await palette(alice, notes, [":", "Banned", "\r", "\r", "\x1b[C", "\r"]);
119 expect(isBanned(app.db, record.id, `u:${bob.id}`)).toBe(false);
120 const sender: Sender = { key: `u:${bob.id}`, name: "@bob", person: true };
121 const again = await sendNote(app, record, notes, "production", undefined, sender, { text: "back again", page: "", viewport: "unknown" }, "ssh:again");
122 expect(again.ok).toBe(true);
123 expect(await palette(alice, notes, [":", "Banned", "\r"])).not.toContain("Unban this sender");
124 });
109125 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs