Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s

This commit is contained in:
snowspeeder committed 2026-09-05 14:56:11 -04:00
1 parent e9f2393c96
commit 824a964f9d
30 files changed
+1721 -908

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, symlink, rm, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { readAlogFromLibrary } from "../server/alog-library.js";
import { setup, signup } from "./helpers.js";
test("server filesystem roast library is restricted to administrators", async () => {
const { agent, db } = await setup();
await signup(agent, "[email protected]");
assert.equal((await agent.get("/api/alog/library")).status, 403);
assert.equal((await agent.get("/api/alog/library/example.alog")).status, 403);
await db.query("UPDATE users SET role='admin' WHERE email=$1", [
"[email protected]",
]);
assert.equal((await agent.get("/api/alog/library")).status, 200);
});
test("library reader rejects path traversal and symlinks outside its directory", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "roast-library-"));
const old = process.env.ALOG_DIR;
process.env.ALOG_DIR = path.join(root, "library");
await mkdir(process.env.ALOG_DIR);
t.after(async () => {
if (old === undefined) delete process.env.ALOG_DIR;
else process.env.ALOG_DIR = old;
await rm(root, { recursive: true, force: true });
});
const content = JSON.stringify({
timex: [0, 60, 120],
temp1: [20, 30, 40],
temp2: [30, 40, 50],
mode: "C",
});
await writeFile(path.join(root, "outside.alog"), content);
await writeFile(path.join(process.env.ALOG_DIR, "inside.alog"), content);
await symlink(
path.join(root, "outside.alog"),
path.join(process.env.ALOG_DIR, "link.alog"),
);
await assert.rejects(readAlogFromLibrary("../outside.alog"), {
code: "bad_filename",
});
await assert.rejects(readAlogFromLibrary("link.alog"), { code: "not_found" });
assert.ok(await readAlogFromLibrary("inside.alog"));
});
+4 -3
View File
@@ -316,7 +316,7 @@ function makeAlog(title = "Roast") {
return JSON.stringify({ title, mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, 14, 0, 0, 0, 20, 0] });
}
test("roaster profile aggregates uploaded roasts; plan chat is grounded in it", async () => {
test("roaster profile describes uploads but is excluded from plan chat", async () => {
const chatCalls = [];
const { agent } = await setup(
{},
@@ -349,7 +349,7 @@ test("roaster profile aggregates uploaded roasts; plan chat is grounded in it",
assert.equal(Number.isFinite(profile.medians.firstCrackTempC), true);
assert.equal(Number.isFinite(profile.rorCPerMin.maillard), true);
// Chat receives the plan, the coerced messages, and both learned profiles
// Chat receives the plan and messages without automatic machine adjustments
const chat = await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({
plan: { fields: { "0.1": "Chat plan", "1.4": "8:30" } },
messages: [{ role: "user", content: "Why is drop so late?" }],
@@ -357,7 +357,8 @@ test("roaster profile aggregates uploaded roasts; plan chat is grounded in it",
assert.equal(chat.status, 200);
assert.equal(chat.body.reply, "Drop 20 seconds earlier.");
assert.equal(chatCalls[0].plan.fields["0.1"], "Chat plan");
assert.equal(chatCalls[0].roasterProfile.n, 2);
assert.equal(chatCalls[0].roasterProfile, undefined);
assert.equal(chatCalls[0].machineProfile, undefined);
assert.equal(Array.isArray(chatCalls[0].messages), true);
// Bad chat bodies are rejected before any model call
+101
View File
@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
isPublicAddress,
resolvePublicUrl,
fetchPageText,
} from "../server/fetch-page.js";
import { setup, signup } from "./helpers.js";
test("prefill rejects loopback, private, mapped IPv6 and reserved IP destinations", async () => {
for (const ip of [
"127.0.0.1",
"10.0.0.1",
"192.168.86.2",
"169.254.169.254",
"100.64.1.1",
"0.0.0.0",
"::1",
"fc00::1",
"fe80::1",
"::ffff:127.0.0.1",
"2002:7f00:1::",
"2001:db8::1",
])
assert.equal(isPublicAddress(ip), false, ip);
for (const ip of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"])
assert.equal(isPublicAddress(ip), true, ip);
for (const url of [
"https://127.1/",
"https://2130706433/",
"https://[::1]/",
"https://user:[email protected]/",
"https://example.com:8443/",
])
await assert.rejects(fetchPageText(url), { code: "destination_rejected" });
});
test("all DNS answers are validated and selected public address is returned for pinning", async () => {
const url = new URL("https://coffee.example/");
await assert.rejects(
resolvePublicUrl(url, async () => [
{ address: "8.8.8.8", family: 4 },
{ address: "10.0.0.1", family: 4 },
]),
{ code: "destination_rejected" },
);
assert.deepEqual(
await resolvePublicUrl(url, async () => [
{ address: "8.8.8.8", family: 4 },
]),
{ address: "8.8.8.8", family: 4 },
);
});
test("authenticated prefill route rejects private URL before model invocation", async () => {
const { agent } = await setup();
const { csrf } = await signup(agent, "[email protected]");
const res = await agent
.post("/api/prefill")
.set("x-csrf-token", csrf)
.send({ url: "https://192.168.86.2/" });
assert.equal(res.status, 422);
assert.equal(res.body.code, "destination_rejected");
});
test("redirects to private destinations are rejected before a second network request", async () => {
let requests = 0;
await assert.rejects(
fetchPageText("https://coffee.example/", {
resolver: async () => [{ address: "8.8.8.8", family: 4 }],
requestPage: async () => {
requests++;
return new Response(null, {
status: 302,
headers: { location: "https://127.0.0.1/" },
});
},
}),
{ code: "destination_rejected" },
);
assert.equal(requests, 1);
});
test("deadline stays active while reading a stalled response body", async () => {
await assert.rejects(
fetchPageText("https://coffee.example/", {
timeoutMs: 20,
resolver: async () => [{ address: "8.8.8.8", family: 4 }],
requestPage: async (_url, signal) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("partial"));
signal.addEventListener(
"abort",
() => controller.error(new Error("body deadline")),
{ once: true },
);
},
}),
{ headers: { "content-type": "text/plain" } },
),
}),
/body deadline/,
);
});
+7 -7
View File
@@ -35,18 +35,18 @@ test("ledger: this is the one pre-existing caller's exact shape — server/prefi
assert.equal(Array.isArray(ledger.warnings), true);
});
test("ledger: pace multiplies the whole pre-pace sum, including the manual batch correction", () => {
test("ledger: ignores machine pace and retains explicit manual corrections", () => {
// anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s base; pace 0.9 -> A = round(540*0.9) = 486s = 8:06.
// This is the fix for the double-counting bug: pace applies to l4 too, not just l1-l3.
const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
assert.equal(ledger.A, 486);
assert.equal(ledger.A, 540);
assert.equal(ledger.lines.l4, 60);
});
test("ledger: subtotalA and pace are exposed and reconstruct A exactly — this is what the printed worksheet's disclosure rows display", () => {
const ledger = computeLedger(plan({ "6.4": "+1:00" }), { pace: { value: 0.9 } });
assert.equal(ledger.subtotalA, 540); // 480 + 60, before pace
assert.equal(ledger.pace, 0.9);
assert.equal(ledger.pace, 1);
assert.equal(ledger.A, Math.round(ledger.subtotalA * ledger.pace));
});
@@ -55,13 +55,13 @@ test("ledger: pace of 1.1 on a plan with refine and bean-condition lines", () =>
const ledger = computeLedger(plan({ "1.6": "+0:15", "5.6": "-0:05" }), {
pace: { value: 1.1 },
});
assert.equal(ledger.A, 539);
assert.equal(ledger.A, 490);
});
test("ledger: yellow and Maillard are derived from the paced A, not the pre-pace sum", () => {
test("ledger: yellow and Maillard stay independent of machine pace", () => {
const ledger = computeLedger(plan(), { pace: { value: 0.9 } });
assert.equal(ledger.A, 432); // 480 * 0.9
assert.equal(ledger.yellow, Math.round(432 * 0.56));
assert.equal(ledger.A, 480); // 480 * 0.9
assert.equal(ledger.yellow, Math.round(480 * 0.56));
assert.equal(ledger.maillard, ledger.A - ledger.yellow);
});
+95
View File
@@ -0,0 +1,95 @@
import test from "node:test";
import assert from "node:assert/strict";
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import { setup } from "./helpers.js";
test(
"browser: unit toggles preserve milestone inputs; HTML download and print share the report",
{ skip: process.env.RUN_BROWSER_TESTS !== "1" },
async (t) => {
const { chromium } = await import("playwright");
const { app } = await setup();
const server = app.listen(0, "127.0.0.1");
await once(server, "listening");
t.after(() => new Promise((r) => server.close(r)));
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox"],
});
t.after(() => browser.close());
const page = await browser.newPage({
viewport: { width: 1440, height: 1100 },
});
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
const base = `http://127.0.0.1:${server.address().port}`;
const signup = await page.request.post(base + "/api/auth/signup", {
data: {
email: "[email protected]",
password: "browser regression password",
},
});
assert.equal(signup.status(), 201);
await page.goto(base + "/app");
for (const [k, v] of Object.entries({
0.1: "Burundi Kayanza Masha",
0.4: "500",
1.1: "Bourbon",
1.4: "8:45",
4.2: "12",
4.3: "1:38",
4.4: "1:15",
5.2: "761.4",
"preheat.tempC": "350",
"preheat.minutes": "20",
"temps.fc.tempC": "392",
}))
await page.locator(`#plan-form input[name="${k}"]`).fill(v);
const temp = page.locator('#plan-form input[name="temps.fc.tempC"]');
for (const [unit, value] of [
["C", "200"],
["F", "392"],
]) {
await page.evaluate((unit) => {
const input = document.querySelector(
`#temp-unit-toggle input[value="${unit}"]`,
);
input.checked = true;
input.dispatchEvent(new Event("change", { bubbles: true }));
}, unit);
assert.equal(await temp.inputValue(), value);
assert.equal(
await page.locator('#sec-machine input[name$=".tempC"]').count(),
6,
);
}
await page
.getByRole("button", { name: "Preview report", exact: true })
.click();
await page
.frameLocator('iframe[title="Printable roast report"]')
.getByRole("heading", { name: "Milestone plan" })
.waitFor();
await page.getByRole("button", { name: "Close preview" }).click();
const pending = page.waitForEvent("download");
await page.getByRole("button", { name: "Download HTML" }).click();
const download = await pending;
assert.match(download.suggestedFilename(), /Burundi.*\.html$/);
const html = await readFile(await download.path(), "utf8");
assert.ok(html.includes("440 g"));
assert.ok(html.includes("350°F"));
assert.ok(html.includes("Development · 1:15"));
assert.ok(!html.includes("°C"));
await page.emulateMedia({ media: "print" });
await page.evaluate(() => window.dispatchEvent(new Event("beforeprint")));
assert.equal(await page.locator("#report-print").isVisible(), true);
assert.equal(await page.locator("#print-sheet").isVisible(), false);
assert.equal(await page.locator("#report-print .sheet").count(), 2);
assert.equal(await page.locator("#report-print svg").count(), 1);
await page.emulateMedia({ media: "screen" });
await page.evaluate(() => window.dispatchEvent(new Event("afterprint")));
await page.goto(base + "/roasters");
assert.deepEqual(errors, []);
},
);
+106
View File
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";
import { blankPlan } from "../shared/fields.js";
import { computeLedger } from "../shared/ledger.js";
import {
buildPlanCurve,
curveSegments,
curveWarnings,
} from "../shared/curve.js";
import { renderReportHtml } from "../shared/report.js";
import { parseDuration } from "../shared/time.js";
function plan() {
const p = blankPlan();
Object.assign(p.fields, {
0.1: "Burundi",
0.4: "500",
1.4: "8:45",
4.1: "light",
4.2: "12",
4.3: "1:38",
3.2: "-0:30",
1.7: "-0:05",
});
return p;
}
test("light development does not silently collapse to 1:03; explicit override wins", () => {
const p = plan();
let l = computeLedger(p);
assert.equal(l.rawC, 63);
assert.equal(l.C, 75);
assert.ok(l.warnings.some((w) => w.includes("below 1:15")));
p.fields["4.4"] = "1:20";
assert.equal(computeLedger(p).C, 80);
});
test("machine profiles cannot modify plan timing or temperature defaults", () => {
const p = plan(),
l = computeLedger(p);
const machine = {
pace: { value: 1.8 },
n: 99,
medians: { firstCrackTempC: 140, turningPointS: 10 },
};
assert.deepEqual(computeLedger(p, machine), l);
assert.deepEqual(buildPlanCurve(p, l, machine), buildPlanCurve(p, l));
assert.equal(buildPlanCurve(p, l)[3].tempC, 200);
});
test("default development average rise declines and cubic segments do not overshoot", () => {
const p = plan(),
l = computeLedger(p),
points = buildPlanCurve(p, l);
assert.deepEqual(curveWarnings(points), []);
for (const s of curveSegments(points).segments) {
for (let t = 0; t <= 1; t += 0.01) {
const v =
(1 - t) ** 3 * s.a.tempC +
3 * (1 - t) ** 2 * t * s.c1.tempC +
3 * (1 - t) * t * t * s.c2.tempC +
t ** 3 * s.b.tempC;
assert.ok(
v >= Math.min(s.a.tempC, s.b.tempC) - 1e-8 &&
v <= Math.max(s.a.tempC, s.b.tempC) + 1e-8,
);
}
}
});
test("explicit flick-producing targets are preserved and flagged", () => {
const p = plan();
p.temps.drop.tempC = "230";
const points = buildPlanCurve(p, computeLedger(p));
assert.equal(points.at(-1).tempC, 230);
assert.ok(curveWarnings(points).length);
});
test("negative timings and implausible density are surfaced", () => {
const p = plan();
p.fields["1.6"] = "-10:00";
p.fields["5.2"] = "76.14";
const l = computeLedger(p);
assert.equal(l.A, null);
assert.ok(l.warnings.some((w) => w.includes("761.4")));
assert.equal(parseDuration("1:99"), null);
assert.equal(parseDuration("9".repeat(400)), null);
});
test("standalone report has Fahrenheit, grams, graph annotations and escapes untrusted content", () => {
const p = plan();
p.fields["0.1"] = "</title><script>alert(1)</script>";
p.afterRoast.cupNotes = "<img src=x onerror=alert(1)>";
const html = renderReportHtml(p);
assert.ok(html.startsWith("<!doctype html>"));
assert.ok(html.includes("440 g"));
assert.ok(html.includes("500 g"));
assert.ok(html.includes("392°F"));
assert.ok(html.includes("Development · 1:15"));
assert.ok(html.includes("8:45"));
assert.ok(html.includes("Maillard"));
assert.ok(html.includes("@media print"));
assert.ok(!html.includes("<script>"));
assert.ok(!html.includes("<img"));
assert.ok(!html.includes("°C"));
assert.ok(!html.includes("Hottop"));
assert.ok(!html.includes("<script src"));
});
test("incomplete plans export without NaN, Infinity or invented output weight", () => {
const html = renderReportHtml(blankPlan());
assert.ok(!/NaN|Infinity/.test(html));
assert.ok(!html.includes("0 g"));
});