Files
kan/packages/auth/src/hooks.test.ts
Nick Meinhold 5f190b92de fix: allow invited users to sign up when registration is disabled (#418)
* fix: allow invited users to sign up when registration is disabled

Move sign-up restriction logic from better-auth's disableSignUp config
to the existing user.create.before database hook, which already checks
for pending invitations. The frontend signup and login pages now detect
invite flows (?next=/invite/...) and bypass the disabled UI accordingly.

Closes #411

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add regression tests for sign-up hook invite bypass

Verify that the user.create.before database hook correctly:
- allows sign-up when registration is not disabled
- blocks sign-up when disabled and no invitation exists
- allows sign-up when disabled but a pending invitation exists
- respects BETTER_AUTH_ALLOWED_DOMAINS in combination with invites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add OIDC/social sign-up path coverage for invite bypass

Address review suggestion: add explicit tests verifying the
user.create.before hook handles OIDC/social sign-ups the same way as
email/password — invited users are allowed, uninvited users are blocked.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:24:58 +00:00

196 lines
5.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next-runtime-env", () => ({
env: vi.fn(),
}));
vi.mock("@kan/db/repository/member.repo", () => ({
getByEmailAndStatus: vi.fn(),
getByPublicId: vi.fn(),
acceptInvite: vi.fn(),
}));
vi.mock("@kan/db/repository/user.repo", () => ({
update: vi.fn(),
}));
vi.mock("@kan/email", () => ({
notificationClient: null,
}));
vi.mock("@kan/shared", () => ({
createEmailUnsubscribeLink: vi.fn(),
createS3Client: vi.fn(),
}));
vi.mock("@aws-sdk/client-s3", () => ({
PutObjectCommand: vi.fn(),
}));
vi.mock("@novu/api/models/components", () => ({
ChatOrPushProviderEnum: { Discord: "discord" },
}));
import { env } from "next-runtime-env";
import * as memberRepo from "@kan/db/repository/member.repo";
import { createDatabaseHooks } from "./hooks";
const mockEnv = env as ReturnType<typeof vi.fn>;
const mockGetByEmailAndStatus =
memberRepo.getByEmailAndStatus as ReturnType<typeof vi.fn>;
const db = {} as Parameters<typeof createDatabaseHooks>[0];
const fakeUser = {
id: "user-1",
createdAt: new Date(),
updatedAt: new Date(),
email: "test@example.com",
emailVerified: false,
name: "Test User",
};
describe("createDatabaseHooks", () => {
const hooks = createDatabaseHooks(db);
beforeEach(() => {
vi.clearAllMocks();
});
describe("user.create.before", () => {
it("allows sign-up when DISABLE_SIGN_UP is not set", async () => {
mockEnv.mockReturnValue(undefined);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
});
it("allows sign-up when DISABLE_SIGN_UP is false", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "false" : undefined,
);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).not.toHaveBeenCalled();
});
it("blocks sign-up when disabled and user has no pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
mockGetByEmailAndStatus.mockResolvedValue(undefined);
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(false);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"test@example.com",
"invited",
);
});
it("allows sign-up when disabled but user has a pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"test@example.com",
"invited",
);
});
it("blocks sign-up when disabled and invitation exists but domain is not allowed", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "acme.com";
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(false);
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
});
// The user.create.before hook fires for ALL sign-up paths including
// OIDC/social — verify invite bypass works regardless of auth method.
it("allows OIDC/social sign-up when disabled but user has a pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
const oidcUser = {
...fakeUser,
id: "user-oidc",
email: "sso@corp.com",
image: "https://provider.com/avatar.jpg",
};
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-2",
email: "sso@corp.com",
status: "invited",
});
const result = await hooks.user.create.before(oidcUser, {});
expect(result).toBe(true);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"sso@corp.com",
"invited",
);
});
it("blocks OIDC/social sign-up when disabled and user has no pending invitation", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
const oidcUser = {
...fakeUser,
id: "user-oidc",
email: "random@external.com",
image: "https://provider.com/avatar.jpg",
};
mockGetByEmailAndStatus.mockResolvedValue(undefined);
const result = await hooks.user.create.before(oidcUser, {});
expect(result).toBe(false);
expect(mockGetByEmailAndStatus).toHaveBeenCalledWith(
db,
"random@external.com",
"invited",
);
});
it("allows sign-up when disabled, invitation exists, and domain is allowed", async () => {
mockEnv.mockImplementation((key: string) =>
key === "NEXT_PUBLIC_DISABLE_SIGN_UP" ? "true" : undefined,
);
process.env.BETTER_AUTH_ALLOWED_DOMAINS = "example.com";
mockGetByEmailAndStatus.mockResolvedValue({
id: "member-1",
email: "test@example.com",
status: "invited",
});
const result = await hooks.user.create.before(fakeUser, {});
expect(result).toBe(true);
delete process.env.BETTER_AUTH_ALLOWED_DOMAINS;
});
});
});