Files
EpicNext-Cms/src/actions/register.test.ts
T
openhands 6bffc53779
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
refactor(auth): merge the duplicate login form and localize the auth screens
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical
components. Delete the former and give `LoginForm` a `variant` prop:

- `variant="page"`   sr-only labels plus the register/forgot footer (/login)
- `variant="compact"` visible labels, no footer (homepage sidebar)

Field ids now come from `useId()`, so the two usages can never collide, and the
hardcoded "Show"/"Hide"/"Loading" strings are translated.

Localization of the login and register screens:

- `home-login-form.tsx` was entirely hardcoded English.
- `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong".
- `register.ts` returned only English strings. It now returns a
  locale-independent `code` next to the message, and the form renders
  `t(code)` with the English string as a fallback.
- Backfilled the new keys across all 25 locales, plus the login/register
  strings that were still English in most of them. `ar`, `fi` and `ja` had
  their entire login/register namespace in English and are now filled in.
  Locale parity stays at 0 missing keys, as `i18n:check` requires.

Copy that did not match the enforced rules: the UI advertised "min 8 chars"
(EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an
uppercase, a lowercase, a digit and a special character. Corrected in every
locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to
match registration.

Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on
any field. All three are now present, and error banners are announced with
`role="alert"`.

Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode`
resolves to a non-empty message in all 25 locales; verified it fails when a key
is removed. The existing register tests now also assert the error `code`.
2026-10-04 18:50:23 +02:00

420 lines
13 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,
code: "usernameMinLength",
});
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(result.code).toBe("usernamePattern");
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,
code: "emailValid",
});
});
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,
code: "passwordMinLength",
});
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");
expect(result.code).toBe("passwordUpper");
});
it("rejects passwords without a digit", async () => {
const result = await register(
PREV,
buildForm({
password: "Secretsecret!",
password_confirmation: "Secretsecret!",
}),
);
expect(result.error).toContain("digit");
expect(result.code).toBe("passwordDigit");
});
it("rejects passwords without a special character", async () => {
const result = await register(
PREV,
buildForm({
password: "Secretsecret1",
password_confirmation: "Secretsecret1",
}),
);
expect(result.error).toContain("special");
expect(result.code).toBe("passwordSpecial");
});
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,
code: "passwordsMatch",
});
});
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(result.code).toBe("rateLimited");
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,
code: "captchaFailed",
});
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,
code: "termsRequired",
});
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,
code: "vpnBlocked",
});
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(result.code).toBe("maxAccountsPerIp");
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,
code: "usernameTaken",
});
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,
code: "unavailable",
});
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,
code: "usernameTaken",
});
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(result.code).toBe("createFailed");
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");
});
});