Rebuild printable roast plans and remove machine-based adjustments
Test and deploy / test-and-deploy (push) Successful in 2m28s
Test and deploy / test-and-deploy (push) Successful in 2m28s
This commit is contained in:
1 parent
e9f2393c96
commit
824a964f9d
30 files changed
+1721
-908
No files matched your search
@@ -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"));
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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, []);
|
||||
},
|
||||
);
|
||||
@@ -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"));
|
||||
});
|
||||
Reference in new issue
Block a user