Add green-bean inventory and cupping features

Ports inventory management and SCA-style cupping scoring from
hope_roaster: lot tracking with audit-logged consumption, cupping
sessions with server-authoritative scoring and a live radar chart,
and links from the planner (draw-from-lot, open-cupping-session).

Also fixes the Blend/Single-origin toggle layout, replaces tooltips
with an in-context "why" teaching layer, and stages the planner UI
into pre-roast vs. post-roast phases.
This commit is contained in:
2026-07-30 14:47:15 -04:00
parent 9f0a3dbc74
commit 59645e59b5
43 changed files with 7582 additions and 566 deletions
+454 -64
View File
@@ -1,77 +1,66 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import request from "supertest";
import { newDb } from "pg-mem";
import { createApp } from "../server/app.js";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const password = "this is a long password";
async function setup() {
const mem = newDb();
mem.public.registerFunction({
name: "gen_random_uuid",
returns: "uuid",
implementation: () => crypto.randomUUID(),
impure: true,
});
const pg = mem.adapters.createPg();
const db = new pg.Pool();
await db.query(
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now()); CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id),csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now()); CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id),plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now()); CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL); INSERT INTO app_settings VALUES('signup_enabled','true')`,
);
const app = createApp({
db,
root,
env: {
NODE_ENV: "test",
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
},
});
return { db, app, agent: request.agent(app) };
}
async function signup(agent, email) {
const response = await agent
.post("/api/auth/signup")
.send({ email, password });
return { response, csrf: response.body.csrfToken };
}
import { root, password, setup, signup } from "./helpers.js";
test("strict CSP/static modules, no-store data, auth lifecycle, and ownership share one database", async () => {
const { db, app, agent: first } = await setup();
const second = request.agent(app);
const anonymous = request.agent(app);
const landing = await anonymous.get("/");
assert.equal(landing.status, 200);
const marketing = await anonymous.get("/");
assert.equal(marketing.status, 200);
assert.match(
landing.headers["content-security-policy"],
marketing.headers["content-security-policy"],
/default-src 'self'/,
);
assert.doesNotMatch(
landing.headers["content-security-policy"],
marketing.headers["content-security-policy"],
/(?:default-src|script-src)[^;]*unsafe-inline/,
);
assert.match(marketing.text, /Get started/);
const login = await anonymous.get("/login");
assert.equal(login.status, 200);
assert.match(
landing.text,
/<script type="module" src="\/js\/landing\.js"><\/script>/,
login.text,
/<script type="module" src="\/js\/login\.js"><\/script>/,
);
assert.doesNotMatch(landing.text, /<script type="module">/);
assert.equal((await anonymous.get("/signup")).status, 200);
assert.equal((await anonymous.get("/forgot")).status, 200);
assert.equal((await anonymous.get("/reset")).status, 200);
assert.equal((await anonymous.get("/api/auth/signup-enabled")).body.enabled, true);
// Page routes redirect a signed-out browser navigation to /login (never a bare JSON 401 —
// that's only for /api/* callers) but the API underneath stays strictly 401/no-store.
const adminHtml = await anonymous.get("/admin");
assert.equal(adminHtml.status, 401);
assert.equal(adminHtml.status, 302);
assert.equal(adminHtml.headers.location, "/login");
assert.equal(adminHtml.headers["cache-control"], "no-store, private");
const accountHtml = await anonymous.get("/account");
assert.equal(accountHtml.status, 302);
assert.equal(accountHtml.headers.location, "/login");
const appHtml = await anonymous.get("/app");
assert.equal(appHtml.status, 302);
assert.equal(appHtml.headers.location, "/login");
assert.equal((await anonymous.get("/api/plans")).status, 401);
assert.equal(
(await anonymous.get("/api/plans")).headers["cache-control"],
"no-store, private",
);
// Protected HTML shells are never reachable by static filename, only through their routes —
// including percent-encoded and repeated-slash variants that bypass an undecoded string
// comparison but still resolve to the same file once express.static decodes them.
assert.equal((await anonymous.get("/index.html")).status, 302);
assert.equal((await anonymous.get("/admin.html")).status, 302);
assert.equal((await anonymous.get("/account.html")).status, 302);
assert.equal((await anonymous.get("/%69ndex.html")).status, 302);
assert.equal((await anonymous.get("//index.html")).status, 302);
assert.match(
(await anonymous.get("/js/admin.js")).text,
/async function load/,
/loadNavUser/,
);
const mainScript = await anonymous.get("/js/main.js");
assert.match(mainScript.text, /roastPlannerPlan\.v2/);
@@ -80,7 +69,7 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
assert.match(serviceWorker, /data-free authenticated shell/);
assert.match(
serviceWorker,
/url\.pathname === "\/app"[\s\S]*caches\.match\("\/app"\)/,
/if \(url\.pathname !== "\/app"\) return;[\s\S]*caches\.match\("\/app"\)/,
);
assert.match(serviceWorker, /logout clears that namespace/);
@@ -88,6 +77,11 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
const two = await signup(second, "[email protected]");
assert.equal(one.response.status, 201);
assert.equal(two.response.status, 201);
// A signed-in visitor is bounced off the public auth pages straight to /app.
assert.equal((await first.get("/login")).status, 302);
assert.equal((await first.get("/")).status, 302);
const plan = await first
.post("/api/plans")
.set("x-csrf-token", one.csrf)
@@ -106,29 +100,62 @@ test("strict CSP/static modules, no-store data, auth lifecycle, and ownership sh
).status,
404,
);
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
const login = request.agent(app);
// A non-UUID id is a clean 404, not a 500 from the database driver.
assert.equal(
(
await login
await first
.put("/api/plans/not-a-uuid")
.set("x-csrf-token", one.csrf)
.send({ plan: {} })
).status,
404,
);
// PUT validates the plan body just like POST does.
assert.equal(
(
await first
.put(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", one.csrf)
.send({ plan: null })
).status,
400,
);
assert.equal((await second.get("/api/plans")).body.plans.length, 0);
// A signed-in non-admin visiting /admin lands back in the app, not a bare 403 JSON page.
const nonAdminVisitsAdmin = await second.get("/admin");
assert.equal(nonAdminVisitsAdmin.status, 302);
assert.equal(nonAdminVisitsAdmin.headers.location, "/app");
assert.equal(
(
await second
.delete(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", two.csrf)
).status,
404,
);
assert.equal(
(
await first
.delete(`/api/plans/${plan.body.plan.id}`)
.set("x-csrf-token", one.csrf)
).status,
200,
);
assert.equal((await first.get("/api/plans")).body.plans.length, 0);
const loginAgent = request.agent(app);
assert.equal(
(
await loginAgent
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
200,
);
assert.equal((await login.get("/api/auth/me")).status, 200);
const loginCsrf = (
await login
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).body.csrfToken;
assert.equal(
(await login.post("/api/auth/logout").set("x-csrf-token", loginCsrf))
.status,
200,
);
assert.equal((await login.get("/api/auth/me")).status, 401);
assert.equal((await loginAgent.get("/api/auth/me")).status, 200);
// Logout succeeds even without a valid CSRF token (it can only end the caller's own session).
assert.equal((await loginAgent.post("/api/auth/logout")).status, 200);
assert.equal((await loginAgent.get("/api/auth/me")).status, 401);
const admin = await first.post("/api/auth/bootstrap").send({
email: "[email protected]",
@@ -198,3 +225,366 @@ test("bootstrap token is optional after first setup and unavailable before setup
409,
);
});
test("/setup redirects to /login once the administrator exists", async () => {
const { app } = await setup();
const agent = request.agent(app);
assert.equal((await agent.get("/setup")).status, 200);
await agent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const fresh = request.agent(app);
const setupResponse = await fresh.get("/setup");
assert.equal(setupResponse.status, 302);
assert.equal(setupResponse.headers.location, "/login");
});
test("forgot/reset password issues a working link without leaking account existence", async () => {
const { app } = await setup();
const memberAgent = request.agent(app);
await signup(memberAgent, "[email protected]");
const admin = request.agent(app);
await admin.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const unknown = await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
assert.equal(unknown.status, 200);
const known = await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
assert.equal(known.status, 200);
assert.deepEqual(known.body, unknown.body);
const pending = await admin.get("/api/admin/password-resets");
assert.equal(pending.status, 200);
const entry = pending.body.links.find((l) => l.email === "[email protected]");
assert.ok(entry, "expected a pending reset link for [email protected]");
const token = new URL(entry.url, "http://x").searchParams.get("token");
assert.equal(
(
await request(app)
.post("/api/auth/reset")
.send({ token: "wrong-token", password: "another long password" })
).status,
400,
);
const resetResponse = await request(app)
.post("/api/auth/reset")
.send({ token, password: "another long password" });
assert.equal(resetResponse.status, 200);
// The old password no longer works; the new one does.
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password: "another long password" })
).status,
200,
);
});
test("account: email change, password change, sessions, and self-delete", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
assert.equal(
(
await agent
.put("/api/account/email")
.set("x-csrf-token", csrf)
.send({ email: "[email protected]", password: "wrong password wrong" })
).status,
401,
);
assert.equal(
(
await agent
.put("/api/account/email")
.set("x-csrf-token", csrf)
.send({ email: "[email protected]", password })
).status,
200,
);
const sessionsBefore = await agent.get("/api/account/sessions");
assert.equal(sessionsBefore.body.sessions.length, 1);
assert.equal(sessionsBefore.body.sessions[0].current, true);
assert.equal(
(
await agent
.put("/api/account/password")
.set("x-csrf-token", csrf)
.send({ currentPassword: password, newPassword: "yet another long one" })
).status,
200,
);
// Logging in with the old password now fails; the new one works.
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
const relogin = request.agent(app);
assert.equal(
(
await relogin
.post("/api/auth/login")
.send({ email: "[email protected]", password: "yet another long one" })
).status,
200,
);
assert.equal(
(
await agent
.delete("/api/account")
.set("x-csrf-token", csrf)
.send({ password: "yet another long one" })
).status,
200,
);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password: "yet another long one" })
).status,
401,
);
});
test("the bootstrap admin's email is reserved and fixed, and the bootstrap admin cannot delete themself even as a second admin exists", async () => {
const { app } = await setup();
// Nobody can claim the reserved email via plain signup before bootstrap ever runs.
assert.equal(
(
await request(app)
.post("/api/auth/signup")
.send({ email: "[email protected]", password })
).status,
409,
);
const bootstrapAgent = request.agent(app);
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
assert.equal(admin.status, 201);
// Someone else still can't claim it after bootstrap (this would also just 409 on the
// column's UNIQUE constraint, but the reserved-email check must fire first regardless).
const otherAgent = request.agent(app);
const { csrf: otherCsrf } = await signup(otherAgent, "[email protected]");
assert.equal(
(
await otherAgent
.put("/api/account/email")
.set("x-csrf-token", otherCsrf)
.send({ email: "[email protected]", password })
).status,
403,
);
// The bootstrap admin can't change away from the reserved email either — that would strip
// every other guard's protection for this account.
assert.equal(
(
await bootstrapAgent
.put("/api/account/email")
.set("x-csrf-token", admin.body.csrfToken)
.send({ email: "[email protected]", password })
).status,
403,
);
// Promote a second admin, then confirm the bootstrap admin still can't delete themself even
// though the "last admin" count check alone would otherwise allow it.
const secondAgent = request.agent(app);
await signup(secondAgent, "[email protected]");
const secondId = (
await bootstrapAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
await bootstrapAgent
.put(`/api/admin/users/${secondId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" });
assert.equal(
(
await bootstrapAgent
.delete("/api/account")
.set("x-csrf-token", admin.body.csrfToken)
.send({ password })
).status,
403,
);
assert.equal((await bootstrapAgent.get("/api/auth/me")).status, 200);
});
test("password-reset links for admin accounts never appear in the shared admin panel", async () => {
const { app } = await setup();
const bootstrapAgent = request.agent(app);
const admin = await bootstrapAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const secondAgent = request.agent(app);
await signup(secondAgent, "[email protected]");
const secondId = (
await bootstrapAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
await bootstrapAgent
.put(`/api/admin/users/${secondId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" });
// The second admin requests a reset for the bootstrap admin's own account...
await request(app)
.post("/api/auth/forgot")
.send({ email: "[email protected]" });
// ...but that link must never surface in the panel any admin (including this one) can read —
// otherwise a second admin could take over the account every other guard protects.
const pending = await secondAgent.get("/api/admin/password-resets");
assert.equal(pending.status, 200);
assert.equal(
pending.body.links.some((l) => l.email === "[email protected]"),
false,
);
});
test("admin: metrics, role changes, disable, delete, and audit trail", async () => {
const { app } = await setup();
const adminAgent = request.agent(app);
const admin = await adminAgent.post("/api/auth/bootstrap").send({
email: "[email protected]",
password,
setupToken: "a-secure-bootstrap-token",
});
const userAgent = request.agent(app);
await signup(userAgent, "[email protected]");
const userId = (
await adminAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
const metrics = await adminAgent.get("/api/admin/metrics");
assert.equal(metrics.status, 200);
assert.equal(metrics.body.metrics.totalUsers, 2);
// The bootstrap admin cannot be demoted, disabled, or deleted by another admin, or by itself.
const bootstrapId = (
await adminAgent.get("/api/admin/users")
).body.users.find((u) => u.email === "[email protected]").id;
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${bootstrapId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "user" })
).status,
403,
);
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${userId}/role`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ role: "admin" })
).status,
200,
);
assert.equal(
(
await adminAgent
.put(`/api/admin/users/${userId}/disabled`)
.set("x-csrf-token", admin.body.csrfToken)
.send({ disabled: true })
).status,
200,
);
// A disabled user's existing session stops working and cannot log back in.
assert.equal((await userAgent.get("/api/auth/me")).status, 401);
assert.equal(
(
await request(app)
.post("/api/auth/login")
.send({ email: "[email protected]", password })
).status,
401,
);
const audit = await adminAgent.get("/api/admin/audit");
assert.equal(audit.status, 200);
assert.ok(audit.body.events.some((e) => e.action === "role_changed"));
assert.ok(audit.body.events.some((e) => e.action === "user_disabled"));
assert.equal(
(
await adminAgent
.delete(`/api/admin/users/${userId}`)
.set("x-csrf-token", admin.body.csrfToken)
).status,
200,
);
assert.equal((await adminAgent.get("/api/admin/metrics")).body.metrics.totalUsers, 1);
});
test("login is locked out per-email after repeated failures, independent of source IP", async () => {
// Uses distinct simulated client IPs (via a trust-proxy app instance) so the assertion
// isolates the per-email lockout from the separate per-IP rate limiter.
const { db } = await setup();
const app = createApp({
db,
root,
env: {
NODE_ENV: "test",
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
TRUST_PROXY: true,
},
});
await signup(request.agent(app), "[email protected]");
for (let i = 0; i < 10; i++) {
await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", `10.0.0.${i}`)
.send({ email: "[email protected]", password: "wrong password wrong" });
}
// Further wrong guesses are locked out...
const stillWrong = await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", "10.0.0.99")
.send({ email: "[email protected]", password: "still not it either" });
assert.equal(stillWrong.status, 429);
assert.equal(stillWrong.body.code, "too_many_attempts");
// ...but the lock never blocks the real owner: the correct password always gets them in,
// so the lockout can only ever throttle guessing, never be weaponized to deny the owner.
const legitimateLogin = await request(app)
.post("/api/auth/login")
.set("X-Forwarded-For", "10.0.0.100")
.send({ email: "[email protected]", password });
assert.equal(legitimateLogin.status, 200);
});
+212
View File
@@ -0,0 +1,212 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
import { computeTotalScore, SCORE_ATTRS, TICK_ATTRS } from "../shared/cupping.js";
async function bootstrapPlan(agent, csrf) {
const r = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Test coffee" } } });
return r.body.plan.id;
}
test("cupping: auth and CSRF are required on every route", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/cupping")).status, 401);
assert.equal((await anon.post("/api/cupping").send({})).status, 401);
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
assert.equal((await agent.post("/api/cupping").send({})).status, 403);
const created = await agent.post("/api/cupping").set("x-csrf-token", csrf).send({});
assert.equal(created.status, 201);
assert.equal(
(await agent.put(`/api/cupping/${created.body.session.id}`).send({ data: {} })).status,
403,
);
assert.equal((await agent.delete(`/api/cupping/${created.body.session.id}`)).status, 403);
});
test("cupping: create (with and without a linked plan), list, and ownership isolation", async () => {
const { app } = await setup();
const first = request.agent(app);
const second = request.agent(app);
const { csrf: firstCsrf } = await signup(first, "[email protected]");
await signup(second, "[email protected]");
const planId = await bootstrapPlan(first, firstCsrf);
const linked = await first
.post("/api/cupping")
.set("x-csrf-token", firstCsrf)
.send({ roastPlanId: planId, cupCount: 4 });
assert.equal(linked.status, 201);
assert.equal(linked.body.session.roastPlanId, planId);
assert.equal(linked.body.session.data.cup_count, 4);
assert.equal(linked.body.session.totalScore, 0);
const unlinked = await first.post("/api/cupping").set("x-csrf-token", firstCsrf).send({});
assert.equal(unlinked.status, 201);
assert.equal(unlinked.body.session.roastPlanId, null);
assert.equal((await first.get("/api/cupping")).body.sessions.length, 2);
assert.equal((await second.get("/api/cupping")).body.sessions.length, 0);
assert.equal((await second.get(`/api/cupping/${linked.body.session.id}`)).status, 404);
const filtered = await first.get(`/api/cupping?plan=${planId}`);
assert.equal(filtered.body.sessions.length, 1);
assert.equal(filtered.body.sessions[0].id, linked.body.session.id);
});
test("cupping: creating against another user's plan is refused", async () => {
const { app } = await setup();
const owner = request.agent(app);
const attacker = request.agent(app);
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
const planId = await bootstrapPlan(owner, ownerCsrf);
const attempt = await attacker
.post("/api/cupping")
.set("x-csrf-token", attackerCsrf)
.send({ roastPlanId: planId });
assert.equal(attempt.status, 404);
});
test("cupping: server always recomputes the total score and ignores a client-supplied value", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 5 })
).body.session;
const scores = Object.fromEntries(SCORE_ATTRS.map((a) => [a, 8]));
const ticks = Object.fromEntries(TICK_ATTRS.map((a) => [a, 5]));
const expected = computeTotalScore(scores, ticks, 1, 1, 5);
const saved = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({
data: {
cup_count: 5,
scores,
ticks,
taint_cups: 1,
fault_cups: 1,
flavor_tags: ["fruity.berry.blueberry"],
notes: "Bright and clean.",
total_score: 999999, // must be discarded — the server recomputes it
},
});
assert.equal(saved.status, 200);
assert.equal(saved.body.session.totalScore, expected);
assert.notEqual(expected, 999999);
const reloaded = await agent.get(`/api/cupping/${session.id}`);
assert.equal(reloaded.body.session.totalScore, expected);
assert.deepEqual(reloaded.body.session.data.flavor_tags, ["fruity.berry.blueberry"]);
});
test("cupping: coerceSession validation errors surface as 400s", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (await agent.post("/api/cupping").set("x-csrf-token", csrf).send({})).body
.session;
const badFlavor = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: ["Not A Valid Id!"] } });
assert.equal(badFlavor.status, 400);
assert.equal(badFlavor.body.code, "bad_session");
const dedupedTags = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: Array.from({ length: 33 }, () => "other.chemical.rubber") } });
// 33 identical tags dedupe to 1, so this specific input should NOT throw — verifies dedup
// happens before the >32 check.
assert.equal(dedupedTags.status, 200);
assert.deepEqual(dedupedTags.body.session.data.flavor_tags, ["other.chemical.rubber"]);
// Build 33 genuinely distinct valid-shaped ids by walking the real taxonomy, to trip the
// actual >32-after-dedup limit.
const { FLAVOR_TAXONOMY } = await import("../shared/cupping.js");
const manyValid = [];
for (const [famId, fam] of Object.entries(FLAVOR_TAXONOMY)) {
for (const [subId, descriptors] of Object.entries(fam.subgroups)) {
for (const d of descriptors) manyValid.push(`${famId}.${subId}.${d}`);
}
}
assert.ok(manyValid.length > 32);
const overLimit = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { flavor_tags: manyValid } });
assert.equal(overLimit.status, 400);
const badStage = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: { stage_marks: [{ stage: "not_a_real_stage", elapsed_sec: 1 }] } });
assert.equal(badStage.status, 400);
const notObject = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({ data: "nope" });
assert.equal(notObject.status, 400);
});
test("cupping: scores clamp/snap and ticks clamp to cup count", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const session = (
await agent.post("/api/cupping").set("x-csrf-token", csrf).send({ cupCount: 3 })
).body.session;
const saved = await agent
.put(`/api/cupping/${session.id}`)
.set("x-csrf-token", csrf)
.send({
data: {
cup_count: 3,
scores: { flavor: 11, acidity: 7.13, body: -1 },
ticks: { uniformity: 99 },
},
});
assert.equal(saved.status, 200);
assert.equal(saved.body.session.data.scores.flavor, 10);
assert.equal(saved.body.session.data.scores.acidity, 7.25);
assert.equal(saved.body.session.data.scores.body, 0);
assert.equal(saved.body.session.data.ticks.uniformity, 3);
});
test("cupping: delete removes the session, and deleting the linked plan nulls roastPlanId instead of deleting the session", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const planId = await bootstrapPlan(agent, csrf);
const session = (
await agent
.post("/api/cupping")
.set("x-csrf-token", csrf)
.send({ roastPlanId: planId })
).body.session;
await agent.delete(`/api/plans/${planId}`).set("x-csrf-token", csrf);
const afterPlanDelete = await agent.get(`/api/cupping/${session.id}`);
assert.equal(afterPlanDelete.status, 200);
assert.equal(afterPlanDelete.body.session.roastPlanId, null);
assert.equal(
(await agent.delete(`/api/cupping/${session.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal((await agent.get(`/api/cupping/${session.id}`)).status, 404);
});
+52
View File
@@ -0,0 +1,52 @@
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import request from "supertest";
import { newDb } from "pg-mem";
import { createApp } from "../server/app.js";
export const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
export const password = "this is a long password";
export async function setup(env = {}) {
const mem = newDb();
mem.public.registerFunction({
name: "gen_random_uuid",
returns: "uuid",
implementation: () => crypto.randomUUID(),
impure: true,
});
const pg = mem.adapters.createPg();
const db = new pg.Pool();
await db.query(
`CREATE TABLE users(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),email text UNIQUE NOT NULL,password_hash text NOT NULL,role text NOT NULL DEFAULT 'user',created_at timestamptz DEFAULT now(),disabled_at timestamptz);
CREATE TABLE sessions(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,csrf_hash text NOT NULL,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now(),user_agent text,ip text,last_seen_at timestamptz);
CREATE TABLE roast_plans(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,plan jsonb NOT NULL,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE app_settings(key text PRIMARY KEY,value text NOT NULL);
INSERT INTO app_settings VALUES('signup_enabled','true');
CREATE TABLE password_reset_tokens(token_hash text PRIMARY KEY,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,expires_at timestamptz NOT NULL,created_at timestamptz DEFAULT now());
CREATE TABLE audit_events(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,action text NOT NULL,target text,created_at timestamptz DEFAULT now());
CREATE TABLE green_bean_lots(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,origin text NOT NULL,variety text NOT NULL DEFAULT '',process text NOT NULL DEFAULT '',producer text NOT NULL DEFAULT '',purchase_date date,initial_weight_g numeric NOT NULL,remaining_weight_g numeric NOT NULL,cost_total numeric,moisture_pct numeric,density_g_l numeric,notes text NOT NULL DEFAULT '',archived boolean NOT NULL DEFAULT false,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now());
CREATE TABLE bean_consumption(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),lot_id uuid NOT NULL REFERENCES green_bean_lots(id) ON DELETE CASCADE,user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,weight_g numeric NOT NULL CHECK (weight_g > 0),created_at timestamptz DEFAULT now());
CREATE UNIQUE INDEX bean_consumption_one_per_plan ON bean_consumption(roast_plan_id);
CREATE TABLE cupping_sessions(id uuid PRIMARY KEY DEFAULT gen_random_uuid(),user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,roast_plan_id uuid REFERENCES roast_plans(id) ON DELETE SET NULL,data jsonb NOT NULL,total_score numeric NOT NULL DEFAULT 0,created_at timestamptz DEFAULT now(),updated_at timestamptz DEFAULT now())`,
);
const app = createApp({
db,
root,
env: {
NODE_ENV: "test",
BOOTSTRAP_SETUP_TOKEN: "a-secure-bootstrap-token",
...env,
},
});
return { db, app, agent: request.agent(app) };
}
export async function signup(agent, email) {
const response = await agent.post("/api/auth/signup").send({ email, password });
return { response, csrf: response.body.csrfToken };
}
+198
View File
@@ -0,0 +1,198 @@
import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup } from "./helpers.js";
async function bootstrapPlan(agent, csrf) {
const r = await agent
.post("/api/plans")
.set("x-csrf-token", csrf)
.send({ plan: { fields: { 0.1: "Test coffee" } } });
return r.body.plan.id;
}
test("inventory: auth and CSRF are required on every route", async () => {
const { app } = await setup();
const anon = request.agent(app);
assert.equal((await anon.get("/api/inventory")).status, 401);
assert.equal((await anon.post("/api/inventory").send({})).status, 401);
const { agent, csrf } = await (async () => {
const a = request.agent(app);
const { csrf: c } = await signup(a, "[email protected]");
return { agent: a, csrf: c };
})();
assert.equal(
(await agent.post("/api/inventory").send({ origin: "X", initialWeightG: 100 })).status,
403,
);
const created = await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 });
assert.equal(created.status, 201);
assert.equal(
(await agent.put(`/api/inventory/${created.body.lot.id}`).send({ origin: "Y" })).status,
403,
);
assert.equal(
(await agent.delete(`/api/inventory/${created.body.lot.id}`)).status,
403,
);
assert.equal(
(
await agent
.post(`/api/inventory/${created.body.lot.id}/consume`)
.send({ weightG: 10 })
).status,
403,
);
});
test("inventory: create, list, and ownership isolation", async () => {
const { app } = await setup();
const first = request.agent(app);
const second = request.agent(app);
const { csrf: firstCsrf } = await signup(first, "[email protected]");
await signup(second, "[email protected]");
const bad = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "", initialWeightG: 100 });
assert.equal(bad.status, 400);
const badWeight = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "Huila", initialWeightG: -5 });
assert.equal(badWeight.status, 400);
const created = await first
.post("/api/inventory")
.set("x-csrf-token", firstCsrf)
.send({ origin: "Huila, Colombia", variety: "Caturra", initialWeightG: 2000 });
assert.equal(created.status, 201);
assert.equal(created.body.lot.remainingWeightG, 2000);
assert.equal((await first.get("/api/inventory")).body.lots.length, 1);
assert.equal((await second.get("/api/inventory")).body.lots.length, 0);
assert.equal((await second.get(`/api/inventory/${created.body.lot.id}`)).status, 404);
});
test("inventory: consume decrements remaining, allows negative, is idempotent per plan, and logs", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const planId = await bootstrapPlan(agent, csrf);
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 300 })
).body.lot;
const first = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 250, roastPlanId: planId });
assert.equal(first.status, 201);
assert.equal(first.body.lot.remainingWeightG, 50);
// A second draw against the SAME plan is rejected — one draw-down per roast plan, ever.
const dupe = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 10, roastPlanId: planId });
assert.equal(dupe.status, 409);
assert.equal(dupe.body.code, "already_consumed");
assert.equal(
(await agent.get(`/api/inventory/${lot.id}`)).body.lot.remainingWeightG,
50,
"remaining must be unchanged after the rejected duplicate",
);
// A manual (plan-less) draw is unlimited and can push remaining negative — an honest
// signal of paperwork/shelf drift, not clamped.
const manual = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 100 });
assert.equal(manual.status, 201);
assert.equal(manual.body.lot.remainingWeightG, -50);
const zero = await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 0 });
assert.equal(zero.status, 400);
const withLog = await agent.get(`/api/inventory/${lot.id}`);
assert.equal(withLog.body.log.length, 2);
// Newest first: the manual (plan-less) draw has no plan title; the earlier draw does.
assert.equal(withLog.body.log[0].roastPlanId, null);
assert.equal(withLog.body.log[0].planTitle, null);
assert.equal(withLog.body.log[1].roastPlanId, planId);
assert.equal(withLog.body.log[1].planTitle, "Test coffee");
});
test("inventory: consuming against another user's plan is refused", async () => {
const { app } = await setup();
const owner = request.agent(app);
const attacker = request.agent(app);
const { csrf: ownerCsrf } = await signup(owner, "[email protected]");
const { csrf: attackerCsrf } = await signup(attacker, "[email protected]");
const planId = await bootstrapPlan(owner, ownerCsrf);
const lot = (
await attacker
.post("/api/inventory")
.set("x-csrf-token", attackerCsrf)
.send({ origin: "Huila", initialWeightG: 500 })
).body.lot;
const consume = await attacker
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", attackerCsrf)
.send({ weightG: 50, roastPlanId: planId });
assert.equal(consume.status, 404);
});
test("inventory: edit never accepts remainingWeightG directly, but shifting initialWeightG shifts remaining by the delta", async () => {
const { app } = await setup();
const agent = request.agent(app);
const { csrf } = await signup(agent, "[email protected]");
const lot = (
await agent
.post("/api/inventory")
.set("x-csrf-token", csrf)
.send({ origin: "Huila", initialWeightG: 1000 })
).body.lot;
await agent
.post(`/api/inventory/${lot.id}/consume`)
.set("x-csrf-token", csrf)
.send({ weightG: 400 });
// Attempting to set remainingWeightG directly is silently ignored.
const sneaky = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ remainingWeightG: 999999 });
assert.equal(sneaky.body.lot.remainingWeightG, 600);
// Correcting the recorded initial weight (e.g. a scale error) shifts remaining by the delta.
const corrected = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ initialWeightG: 1100 });
assert.equal(corrected.body.lot.initialWeightG, 1100);
assert.equal(corrected.body.lot.remainingWeightG, 700);
const archived = await agent
.put(`/api/inventory/${lot.id}`)
.set("x-csrf-token", csrf)
.send({ archived: true });
assert.equal(archived.body.lot.archived, true);
assert.equal(
(await agent.delete(`/api/inventory/${lot.id}`).set("x-csrf-token", csrf)).status,
200,
);
assert.equal((await agent.get(`/api/inventory/${lot.id}`)).status, 404);
});