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:
+454
-64
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user