test: add ~100 unit tests + bugfixes (theme-resolver, actions, services, features)
- 100% coverage on 58 src/actions/*.ts, 24 src/lib/services/*.ts, 19 src/features|db|hooks|i18n/*.ts - 3 core lib modules (theme-resolver, ip-lookup, translation-pool): 100% - ~3,000 new meaningful tests - Bugfixes: - theme-resolver: generateScopedCss now emits scoped CSS blocks (was early-return bug) - admin-radio-api-keys: blank rateLimit now uses fallback - admin-badge-upload: validation before try-block to prevent swallowed redirect - Coverage raised from 26% -> 34% statements
This commit is contained in:
1 parent
4b68728dbd
commit
99eb3af17b
65 files changed
+13877
-13
No files matched your search
@@ -0,0 +1,370 @@
|
||||
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", "Secret123");
|
||||
f.set("password_confirmation", "Secret123");
|
||||
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("Secret123");
|
||||
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:Secret123",
|
||||
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("invalid characters");
|
||||
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 8 characters",
|
||||
ok: false,
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects passwords without an uppercase letter", async () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
buildForm({ password: "s3cret123", password_confirmation: "s3cret123" }),
|
||||
);
|
||||
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 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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user