文件
hyb-oa/server/tests/unit/app.auth-security-config.test.js
T

174 行
5.2 KiB
JavaScript

const crypto = require("node:crypto");
const fp = require("fastify-plugin");
const { buildApp } = require("../../src/app");
const { getConfig } = require("../../src/config");
const {
generateSixDigitCode,
hashAuthCode,
timingSafeEqualHex,
} = require("../../src/utils/crypto");
const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
function stubBaseEnv(nodeEnv = "test") {
vi.stubEnv("NODE_ENV", nodeEnv);
vi.stubEnv("JWT_SECRET", "test-jwt-secret");
vi.stubEnv("DATABASE_URL", "");
vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : "");
vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined);
vi.stubEnv("JOBS_ENABLED", "false");
vi.stubEnv("METRICS_ENABLED", "false");
vi.stubEnv("SERVE_CLIENT_DIST", "false");
vi.stubEnv("OPENAPI_DOCS_ENABLED", "false");
}
function createPrismaPlugin() {
return fp(async (app) => {
app.decorate("prisma", {
$queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]),
employee: {
findUnique: vi.fn(),
},
});
}, { name: "test-prisma" });
}
async function buildIpProbeApp() {
const app = await buildApp({
prismaPlugin: createPrismaPlugin(),
startJobs: vi.fn(async () => ({ driver: "disabled" })),
});
app.get("/__test/ip", async (request) => ({
ip: request.ip,
ips: request.ips,
}));
return app;
}
describe("auth security configuration", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it("uses crypto.randomInt for padded six-digit codes", () => {
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42);
expect(generateSixDigitCode()).toBe("000042");
expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000);
});
it("separates auth code hashes by purpose and compares them safely", () => {
const common = {
employeeId: "11111111-1111-4111-8111-111111111111",
code: "123456",
pepper: STRONG_PEPPER,
};
const emailHash = hashAuthCode({ ...common, purpose: "email_login" });
const resetHash = hashAuthCode({ ...common, purpose: "password_reset" });
expect(emailHash).not.toBe(resetHash);
expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true);
expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false);
expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false);
});
it("fails closed when production has no auth code pepper", () => {
stubBaseEnv("production");
vi.stubEnv("AUTH_CODE_PEPPER", "");
expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER");
});
it.each([
"<managed-auth-code-pepper>",
"short-secret",
])("rejects a weak production auth code pepper", (pepper) => {
stubBaseEnv("production");
vi.stubEnv("AUTH_CODE_PEPPER", pepper);
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret");
});
it("rejects a production auth code pepper reused as the JWT secret", () => {
stubBaseEnv("production");
vi.stubEnv("JWT_SECRET", STRONG_PEPPER);
vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER);
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
});
it("forbids development password reset code logging in production", () => {
stubBaseEnv("production");
vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true");
expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
});
it.each([
[undefined, "Missing required env: TRUST_PROXY_HOPS"],
["", "Invalid TRUST_PROXY_HOPS"],
["0", "Invalid TRUST_PROXY_HOPS"],
["2", "Invalid TRUST_PROXY_HOPS"],
["true", "Invalid TRUST_PROXY_HOPS"],
])("requires exactly one trusted proxy hop in production", (value, error) => {
stubBaseEnv("production");
vi.stubEnv("TRUST_PROXY_HOPS", value);
expect(() => getConfig()).toThrow(error);
});
it.each([
["PASSWORD_RESET_MAX_ATTEMPTS", "0"],
["PASSWORD_RESET_LOCK_SECONDS", "-1"],
["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"],
["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"],
])("rejects unsafe numeric auth configuration", (name, value) => {
stubBaseEnv();
vi.stubEnv(name, value);
expect(() => getConfig()).toThrow(`Invalid ${name}`);
});
it("ignores a forged X-Forwarded-For header by default", async () => {
stubBaseEnv();
const app = await buildIpProbeApp();
try {
const response = await app.inject({
method: "GET",
url: "/__test/ip",
headers: { "x-forwarded-for": "10.10.10.10" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ ip: "127.0.0.1" });
} finally {
await app.close();
}
}, 20_000);
it("trusts only the nearest address supplied by one controlled proxy", async () => {
stubBaseEnv();
vi.stubEnv("TRUST_PROXY_HOPS", "1");
const app = await buildIpProbeApp();
try {
const response = await app.inject({
method: "GET",
url: "/__test/ip",
headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
ip: "192.168.1.20",
ips: ["127.0.0.1", "192.168.1.20"],
});
} finally {
await app.close();
}
}, 20_000);
});