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

An account's model grant and spend ceilings, set by an admin, speak the home page's language #9900

Merged opened this

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

Merged

mason/forge:mason/9897-an-account-s-model-grant-and-spend-ceili → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/9897-an-account-s-model-grant-and-spend-ceili was merged into main as 1e6d8d9.

Review

Sign in to review this change.
2 files changed +119 −28
src/web/pages/model.ts +19 −28
@@ -23,7 +23,7 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
2323 import { field, readForm } from "../form.ts";
2424 import { html, type Html } from "../html.ts";
2525 import { flashTo, page } from "../layout.ts";
26 import { btn, card, row } from "../ui.ts";
26 import { btn, row } from "../ui.ts";
2727 import { accountOnly, accountSections } from "./origins.ts";
2828
2929 // GET /settings/model
@@ -101,17 +101,18 @@ export async function modelAdd(ctx: Ctx): Promise<Response> {
101101 );
102102 }
103103
104 /** A heading over its content, no box: `card` with a head, minus the box. */
105 function settingsBlock(body: Html, opts: { head: Html }): Html {
106 return html`<section class="settings-block">${opts.head}${body}</section>`;
107 }
108
109104 /** The admin's card for an account's spend ceilings, on a person's or an organization's page. */
110 export function ceilingCard(ctx: Ctx, user: User, at: string, plain = false): Html {
105 export function ceilingCard(ctx: Ctx, user: User, at: string): Html {
111106 const ceiling = getAccountCeiling(ctx.app.db, user.id);
112107 const left = dailyLeft(ctx.app.db, user.id, new Date());
113 return (plain ? settingsBlock : card)(
114 html`<form class="stack" method="post" action="${at}/ceiling">
108 return html`<section class="settings-block">
109 <h2>Spend ceilings</h2>
110 <p class="muted" id="ceiling-line">
111 ${ceiling === undefined || left === undefined
112 ? "No ceiling set."
113 : `$${ceiling.dailyUsd.toFixed(2)} a day, $${ceiling.perRunUsd.toFixed(2)} a run; $${left.toFixed(2)} left today.`}
114 </p>
115 <form class="stack" method="post" action="${at}/ceiling">
115116 <div class="field">
116117 <label for="f-daily">Daily ceiling, dollars</label>
117118 <input class="input" id="f-daily" name="daily" inputmode="decimal"
@@ -126,24 +127,18 @@ export function ceilingCard(ctx: Ctx, user: User, at: string, plain = false): Ht
126127 ceiling.</span>
127128 </div>
128129 <div>${btn("Save", { variant: "primary", size: "sm" })}</div>
129 </form>`,
130 {
131 head: html`<h2>Spend ceilings</h2>
132 <p class="muted" id="ceiling-line">
133 ${ceiling === undefined || left === undefined
134 ? "No ceiling set."
135 : `$${ceiling.dailyUsd.toFixed(2)} a day, $${ceiling.perRunUsd.toFixed(2)} a run; $${left.toFixed(2)} left today.`}
136 </p>`,
137 },
138 );
130 </form>
131 </section>`;
139132 }
140133
141134 /** The admin's card on a person's page: what they have, and the grant form. */
142135 export function grantCard(ctx: Ctx, user: User, at: string): Html {
143136 const grant = getModelGrant(ctx.app.db, user.id);
144 const ceilings = ceilingCard(ctx, user, at, true);
145 const grantForm = settingsBlock(
146 html`<form class="stack" method="post" action="${at}/model">
137 const ceilings = ceilingCard(ctx, user, at);
138 const grantForm = html`<section class="settings-block">
139 <h2>Model budget</h2>
140 <p class="muted">${budgetLine(ctx.app.db, user, new Date())}</p>
141 <form class="stack" method="post" action="${at}/model">
147142 <div class="field">
148143 <label for="f-grant">Grant, dollars a month</label>
149144 <input class="input" id="f-grant" name="usd" inputmode="decimal"
@@ -153,12 +148,8 @@ export function grantCard(ctx: Ctx, user: User, at: string): Html {
153148 $${GRANT_MAX_USD} a month; a run past the cap stops.</span>
154149 </div>
155150 <div>${btn("Save", { variant: "primary", size: "sm" })}</div>
156 </form>`,
157 {
158 head: html`<h2>Model budget</h2>
159 <p class="muted">${budgetLine(ctx.app.db, user, new Date())}</p>`,
160 },
161 );
151 </form>
152 </section>`;
162153 return html`${grantForm}${ceilings}`;
163154 }
164155
test/model-language.test.ts added +100
@@ -0,0 +1,100 @@
1 /** An account's model grant and spend ceilings speak the home page's language (#9897): sections under h2, 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 } from "../src/app.ts";
7 import { createOrg } from "../src/core/orgs.ts";
8 import { createUser, getUserByName } from "../src/core/users.ts";
9 import { serve } from "../src/http/server.ts";
10
11 let dir = "";
12 let app: ReturnType<typeof createApp>;
13 let servers: Awaited<ReturnType<typeof serve>>;
14 const cookies: Record<string, string> = {};
15
16 beforeAll(async () => {
17 dir = await mkdtemp(join(tmpdir(), "forge-model-lang-"));
18 app = createApp({ dataDir: dir, port: 0 });
19 servers = await serve(app);
20 for (const [username, first] of [["alice", true], ["bob", false]] as const) {
21 await createUser(app.db, {
22 origin: "seed", trusted: true, username, email: `${username}@forge.local`,
23 password: "a-good-passphrase", adminIfFirst: first,
24 });
25 const res = await fetch(servers.url + "/signin", {
26 method: "POST",
27 redirect: "manual",
28 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
29 body: new URLSearchParams({ username, password: "a-good-passphrase" }),
30 });
31 cookies[username] = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
32 }
33 });
34 afterAll(async () => {
35 servers.stop();
36 app.close();
37 await rm(dir, { recursive: true, force: true });
38 });
39
40 const main = (page: string): string => page.slice(page.indexOf("<main"), page.indexOf("</main>"));
41
outdated

This assertion passes on main too, because grantCard already passed plain=true. Add the organization page (ceilingCard without plain), which is the only output this change alters.

42 test("the grant and the ceilings are plain sections under h2, posting where they did", async () => {
43 const res = await fetch(servers.url + "/settings/accounts/bob", { headers: { cookie: cookies.alice! } });
44 expect(res.status).toBe(200);
45 const page = main(await res.text());
46 const at = page.indexOf("<h2>Model budget</h2>");
47 expect(at).toBeGreaterThan(-1);
48 const mine = page.slice(at);
49 expect(mine).toContain("<h2>Spend ceilings</h2>");
50 expect(mine).toContain('action="/settings/accounts/bob/model"');
51 expect(mine).toContain('action="/settings/accounts/bob/ceiling"');
52 expect(mine).toContain('name="usd"');
53 expect(mine).toContain('name="daily"');
54 // Each heading opens a plain section, not a card.
55 for (const h of ["<h2>Model budget</h2>", "<h2>Spend ceilings</h2>"])
56 expect(page.slice(page.indexOf(h) - 60, page.indexOf(h)).replace(/\s+/g, " ")).toContain('<section class="settings-block">');
57 // The grant and ceiling sections hold no card around their forms.
58 const grant = page.slice(at, page.indexOf("</section>", at));
59 expect(grant).not.toContain("card");
60 const ceilingAt = page.indexOf("<h2>Spend ceilings</h2>");
61 expect(page.slice(ceilingAt, page.indexOf("</section>", ceilingAt))).not.toContain("card");
62 });
63
64 test("an organization's page is its spend ceilings, a plain section under h2", async () => {
65 const founder = getUserByName(app.db, "alice")!;
66 expect(createOrg(app.db, "acme", founder.id).ok).toBe(true);
67 const res = await fetch(servers.url + "/settings/accounts/acme", { headers: { cookie: cookies.alice! } });
68 expect(res.status).toBe(200);
69 const page = main(await res.text());
70 const at = page.indexOf("<h2>Spend ceilings</h2>");
71 expect(at).toBeGreaterThan(-1);
72 expect(page.slice(Math.max(0, at - 60), at).replace(/\s+/g, " ")).toContain('<section class="settings-block">');
73 expect(page).toContain('action="/settings/accounts/acme/ceiling"');
74 expect(page).not.toContain("card");
75 });
76
77 test("a ceiling still posts for an admin only, behind the same-origin check", async () => {
78 const body = new URLSearchParams({ daily: "5", perRun: "1" });
79 const headers = { "content-type": "application/x-www-form-urlencoded" };
80 const url = servers.url + "/settings/accounts/bob/ceiling";
81 const asBob = await fetch(url, {
82 method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.bob!, origin: servers.url },
83 });
84 expect(asBob.status).toBe(404);
85 const foreign = await fetch(url, {
86 method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: "https://evil.test" },
87 });
88 expect(foreign.status).toBeGreaterThanOrEqual(400);
89 const ok = await fetch(url, {
90 method: "POST", redirect: "manual", body, headers: { ...headers, cookie: cookies.alice!, origin: servers.url },
91 });
92 expect(ok.status).toBe(303);
93 });
94
95 test("the person's own model page still takes a token and shows none back", async () => {
96 const res = await fetch(servers.url + "/settings/model", { headers: { cookie: cookies.bob! } });
97 const page = main(await res.text());
98 expect(page).toContain("<h2>Your model budget</h2>");
99 expect(page).toContain('type="password"');
100 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs