Files
EpicNext-Cms/src/actions/register.test.ts
T
openhands b13b3a50ff
CI / check (push) Failing after 1m35s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
security: switch default hashing to Argon2id, fix tests
- hashPassword now uses Argon2id (memory-hard, GPU-resistant) via hash-wasm
- verifyPassword checks both Argon2id and bcrypt
- Legacy hashes (bcrypt, argon2, md5, sha1, sha256, sha512, combined, salted)
  auto-migrate to Argon2id on successful login
- Updated all password tests to expect Argon2id format
- Register validation: min 12 chars, max 128, upper+lower+digit+special required
- Username restricted to [A-Za-z0-9_-], reserved names blocked
- Disposable email domains blocked
- Fixed parameter names for hash-wasm argon2id API (memorySize, iterations, parallelism, hashLength)
2026-09-21 19:48:31 +02:00

397 lines
12 KiB
TypeScript

// @ts-nocheck
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rateLimit: vi.fn(async () => ({ ok: true })),
clientIp: vi.fn(async () => "203.0.113.9"),
revalidatePath: vi.fn(),
captchaConfig: vi.fn(async () => ({
provider: "none",
siteKey: "",
field: "cf-turnstile-response",
})),
verifyCaptcha: vi.fn(async () => true),
siteGet: vi.fn(async () => ""),
siteGetBool: vi.fn(async () => false),
checkVpn: vi.fn(async () => ({ blocked: false })),
hashPassword: vi.fn(async (password: string) => `hashed:${password}`),
invalidateKey: vi.fn(async () => 1),
recordReferral: vi.fn(async () => undefined),
sendVerification: vi.fn(async () => undefined),
logger: { warn: vi.fn(), error: vi.fn() },
after: vi.fn(),
afterCb: null as null | (() => Promise<void>),
insert: vi.fn(async () => [{ insertId: 42 }]),
userRows: [] as Array<{ id: number }>,
countTotal: 0,
failCount: false,
failUsernameCheck: false,
}));
vi.mock("next/server", () => ({
after: state.after,
}));
vi.mock("@/lib/auth/email-verification", () => ({
sendVerification: state.sendVerification,
}));
vi.mock("@/lib/auth/password", () => ({
hashPassword: state.hashPassword,
}));
vi.mock("@/lib/cached-db", () => ({ invalidateKey: state.invalidateKey }));
vi.mock("@/lib/logger", () => ({ logger: state.logger }));
vi.mock("@/lib/rate-limit", () => ({
rateLimit: state.rateLimit,
clientIp: state.clientIp,
}));
vi.mock("@/lib/services/captcha", () => ({
captchaConfig: state.captchaConfig,
verifyCaptcha: state.verifyCaptcha,
}));
vi.mock("@/lib/services/ip-lookup", () => ({ checkVpn: state.checkVpn }));
vi.mock("@/lib/services/referrals", () => ({
recordReferral: state.recordReferral,
}));
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: {
get: state.siteGet,
getBool: state.siteGetBool,
},
}));
vi.mock("@/lib/db", async () => {
const schema = await import("@/db/schema");
const { createFakeDb } = await import("@/test/fake-db");
const fake = createFakeDb(
(_table: unknown, projection: Record<string, unknown>) => {
if ("total" in projection) {
if (state.failCount) return Promise.reject(new Error("count down"));
return [{ total: state.countTotal }];
}
if (state.failUsernameCheck) throw new Error("check down");
return state.userRows;
},
);
return {
...schema,
db: {
...fake,
insert: (table: unknown) => ({
values: (values: unknown) => state.insert(table, values),
}),
},
};
});
import { User } from "@/lib/db";
import { register } from "./register";
const PREV: { error: string | null; ok: boolean } = { error: null, ok: true };
function buildForm(overrides: Record<string, string | undefined> = {}) {
const f = new FormData();
f.set("username", "Alice_123");
f.set("mail", "");
f.set("password", "Secret1234!@"); // 12+ chars, upper, lower, digit, special
f.set("password_confirmation", "Secret1234!@");
f.set("terms", "on");
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) f.delete(k);
else f.set(k, v);
}
return f;
}
async function runValidRegistration() {
return await register(PREV, buildForm());
}
describe("register", () => {
beforeEach(() => {
vi.clearAllMocks();
state.rateLimit.mockResolvedValue({ ok: true });
state.siteGet.mockImplementation(async () => "");
state.siteGetBool.mockResolvedValue(false);
state.checkVpn.mockResolvedValue({ blocked: false });
state.captchaConfig.mockResolvedValue({
provider: "none",
siteKey: "",
field: "cf-turnstile-response",
});
state.verifyCaptcha.mockResolvedValue(true);
state.hashPassword.mockImplementation(
async (password: string) => `hashed:${password}`,
);
state.insert.mockResolvedValue([{ insertId: 42 }]);
state.afterCb = null;
state.after.mockImplementation((cb: () => Promise<void>) => {
state.afterCb = cb;
});
state.userRows = [];
state.countTotal = 0;
state.failCount = false;
state.failUsernameCheck = false;
state.sendVerification.mockResolvedValue(undefined);
state.recordReferral.mockResolvedValue(undefined);
});
it("creates the account and returns ok", async () => {
const result = await runValidRegistration();
expect(result).toEqual({ error: null, ok: true });
expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@");
expect(state.insert).toHaveBeenCalledOnce();
expect(state.insert.mock.calls[0][0]).toBe(User);
expect(state.insert.mock.calls[0][1]).toMatchObject({
username: "Alice_123",
password: "hashed:Secret1234!@",
mail: null,
accountCreated: expect.any(Number),
ipRegister: "203.0.113.9",
ipCurrent: "203.0.113.9",
look: "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62",
termsAccepted: true,
});
expect(state.invalidateKey).toHaveBeenCalledWith("login:user:Alice_123");
expect(state.recordReferral).not.toHaveBeenCalled();
expect(state.after).not.toHaveBeenCalled();
});
it("normalizes the username and lowercases the trimmed mail", async () => {
await register(
PREV,
buildForm({ username: " Bob_88 ", mail: " [email protected] " }),
);
const values = state.insert.mock.calls[0][1] as {
username: string;
mail: string;
};
expect(values.username).toBe("Bob_88");
expect(values.mail).toBe("[email protected]");
expect(state.after).toHaveBeenCalledOnce();
await state.afterCb?.();
expect(state.sendVerification).toHaveBeenCalledWith("[email protected]");
});
it("logs a warning when the verification email fails to send", async () => {
state.sendVerification.mockRejectedValue(new Error("smtp down"));
await register(PREV, buildForm({ mail: "[email protected]" }));
await state.afterCb?.();
expect(state.logger.warn).toHaveBeenCalledWith(
"Failed to send verification email after registration",
);
});
it("rejects short usernames", async () => {
const result = await register(PREV, buildForm({ username: "ab" }));
expect(result).toEqual({
error: "Username must be at least 3 characters",
ok: false,
});
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects usernames containing characters outside the allowed set", async () => {
const result = await register(PREV, buildForm({ username: "bad name!" }));
expect(result.error).toContain("letters, numbers, underscore and hyphen");
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects invalid emails", async () => {
const result = await register(PREV, buildForm({ mail: "not-an-email" }));
expect(result).toEqual({
error: "Enter a valid email address",
ok: false,
});
});
it("rejects weak passwords", async () => {
const result = await register(PREV, buildForm({ password: "short" }));
expect(result).toEqual({
error: "Password must be at least 12 characters",
ok: false,
});
expect(state.insert).not.toHaveBeenCalled();
});
it("rejects passwords without an uppercase letter", async () => {
const result = await register(
PREV,
buildForm({
password: "secret1234!@",
password_confirmation: "secret1234!@",
}),
);
expect(result.error).toContain("uppercase");
});
it("rejects passwords without a digit", async () => {
const result = await register(
PREV,
buildForm({
password: "Secretsecret!",
password_confirmation: "Secretsecret!",
}),
);
expect(result.error).toContain("digit");
});
it("rejects passwords without a special character", async () => {
const result = await register(
PREV,
buildForm({
password: "Secretsecret1",
password_confirmation: "Secretsecret1",
}),
);
expect(result.error).toContain("special");
});
it("rejects mismatched password confirmations", async () => {
const result = await register(
PREV,
buildForm({ password_confirmation: "Different1" }),
);
expect(result).toEqual({ error: "Passwords do not match", ok: false });
});
it("throttles sign-ups per IP", async () => {
state.rateLimit.mockResolvedValueOnce({ ok: false, retryAfter: 120 });
const result = await runValidRegistration();
expect(result.error).toContain("Too many sign-up attempts");
expect(state.insert).not.toHaveBeenCalled();
});
it("verifies the CAPTCHA when one is configured", async () => {
state.captchaConfig.mockResolvedValue({
provider: "turnstile",
siteKey: "key",
field: "cf-turnstile-response",
});
state.verifyCaptcha.mockResolvedValueOnce(false);
const result = await register(
PREV,
buildForm({ "cf-turnstile-response": "token" }),
);
expect(result).toEqual({
error: "Captcha verification failed. Please try again.",
ok: false,
});
expect(state.verifyCaptcha).toHaveBeenCalledWith("token", "203.0.113.9");
expect(state.insert).not.toHaveBeenCalled();
state.verifyCaptcha.mockResolvedValueOnce(true);
const ok = await register(
PREV,
buildForm({ "cf-turnstile-response": "token" }),
);
expect(ok).toEqual({ error: null, ok: true });
});
it("requires accepting the terms", async () => {
const result = await register(PREV, buildForm({ terms: undefined }));
expect(result).toEqual({
error: "You must accept the terms and conditions to register.",
ok: false,
});
expect(state.insert).not.toHaveBeenCalled();
});
it("blocks VPN registrations with the configured message", async () => {
state.checkVpn.mockResolvedValue({ blocked: true });
state.siteGet.mockResolvedValueOnce("Custom VPN message");
const result = await runValidRegistration();
expect(result).toEqual({ error: "Custom VPN message", ok: false });
expect(state.insert).not.toHaveBeenCalled();
});
it("blocks VPN registrations with the default message when unset", async () => {
state.checkVpn.mockResolvedValue({ blocked: true });
state.siteGet.mockResolvedValueOnce("");
const result = await runValidRegistration();
expect(result.error).toBe(
"Registrations from VPN/proxy connections are not allowed.",
);
expect(state.insert).not.toHaveBeenCalled();
});
it("blocks the max-account-per-IP cap when the count is reached", async () => {
state.siteGet.mockResolvedValueOnce("2");
state.countTotal = 2;
const result = await runValidRegistration();
expect(result.error).toContain("maximum number of accounts");
expect(state.insert).not.toHaveBeenCalled();
});
it("allows registration below the max-account cap", async () => {
state.siteGet.mockResolvedValueOnce("2");
state.countTotal = 1;
const result = await runValidRegistration();
expect(result).toEqual({ error: null, ok: true });
});
it("treats a failed account count as unlimited", async () => {
state.siteGet.mockResolvedValueOnce("2");
state.failCount = true;
const result = await runValidRegistration();
expect(result).toEqual({ error: null, ok: true });
});
it("rejects an already-taken username", async () => {
state.userRows = [{ id: 7 }];
const result = await runValidRegistration();
expect(result).toEqual({
error: "That username is already taken",
ok: false,
});
expect(state.insert).not.toHaveBeenCalled();
});
it("returns a temporary failure when the uniqueness check itself fails", async () => {
state.failUsernameCheck = true;
const result = await runValidRegistration();
expect(result).toEqual({
error: "Registration is temporarily unavailable",
ok: false,
});
expect(state.logger.warn).toHaveBeenCalledWith(
"Username uniqueness check failed during registration",
);
expect(state.insert).not.toHaveBeenCalled();
});
it("maps duplicate-key insert errors to taken username and stays dry on logging", async () => {
state.insert.mockRejectedValueOnce({
cause: { code: "ER_DUP_ENTRY" },
});
const result = await runValidRegistration();
expect(result).toEqual({
error: "That username is already taken",
ok: false,
});
expect(state.logger.error).not.toHaveBeenCalled();
});
it("returns a generic creation failure on unexpected insert errors and logs them", async () => {
state.insert.mockRejectedValueOnce(new Error("db exploded"));
const result = await runValidRegistration();
expect(result.error).toContain("Could not create the account");
expect(state.logger.error).toHaveBeenCalledWith(
"Account creation failed",
expect.objectContaining({ message: "db exploded" }),
);
});
it("records a referral when the inviter is provided", async () => {
await register(PREV, buildForm({ ref: "Veteran" }));
expect(state.recordReferral).toHaveBeenCalledWith({
inviterUsername: "Veteran",
inviteeId: 42,
inviteeIp: "203.0.113.9",
});
});
it("uses a custom look when one is supplied", async () => {
await register(PREV, buildForm({ look: "hr-123-42.hd-180-1" }));
const values = state.insert.mock.calls[0][1] as { look: string };
expect(values.look).toBe("hr-123-42.hd-180-1");
});
});