Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion examples/oidc/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ try {
* return false;
* }
*/
validateEmail: async () => true,
validateEmail: async () => ({ valid: true as const }),
},
},
adminUiConfig: {
Expand Down
14 changes: 12 additions & 2 deletions packages/api/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,14 +241,24 @@ export const CyclicProductBundlingNotSupportedError = createError(
'Cyclic bundling detected, make sure bundled product is not the same as the bundle product itself',
);

export const EmailFormatInvalidError = createError(
'EmailFormatInvalid',
'Email address format is invalid',
);

export const EmailAlreadyExistsError = createError(
'EmailAlreadyExists',
'Email already exists or is invalid',
'Email address already exists',
);

export const UsernameTooShortError = createError(
'UsernameTooShort',
'Username must be at least 3 characters',
);

export const UsernameAlreadyExistsError = createError(
'UsernameAlreadyExists',
'Username already exists or is invalid',
'Username already exists',
);

export const UsernameOrEmailRequiredError = createError(
Expand Down
20 changes: 12 additions & 8 deletions packages/core-users/src/module/configureUsersModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,9 @@ export const configureUsersModule = async (moduleInput: ModuleInput<UserSettings
const services: Record<string, any> = {};

if (email) {
if (!(await userSettings.validateEmail(email))) {
throw new Error(`E-Mail address ${email} is invalid`, { cause: 'EMAIL_INVALID' });
const emailResult = await userSettings.validateEmail(email);
if (!emailResult.valid) {
throw new Error(`E-Mail address ${email} is invalid`, { cause: emailResult.reason });
}
}

Expand Down Expand Up @@ -353,8 +354,9 @@ export const configureUsersModule = async (moduleInput: ModuleInput<UserSettings
};

if (username) {
if (!(await userSettings.validateUsername(username))) {
throw new Error(`Username ${username} is invalid`, { cause: 'USERNAME_INVALID' });
const usernameResult = await userSettings.validateUsername(username);
if (!usernameResult.valid) {
throw new Error(`Username ${username} is invalid`, { cause: usernameResult.reason });
}
doc.username = username;
}
Expand Down Expand Up @@ -411,8 +413,9 @@ export const configureUsersModule = async (moduleInput: ModuleInput<UserSettings
},

async addEmail(userId: string, address: string): Promise<void> {
if (!(await userSettings.validateEmail(address))) {
throw new Error(`E-Mail address ${address} is invalid`, { cause: 'EMAIL_INVALID' });
const emailResult = await userSettings.validateEmail(address);
if (!emailResult.valid) {
throw new Error(`E-Mail address ${address} is invalid`, { cause: emailResult.reason });
}
await this.updateUser(
{ _id: userId, 'emails.address': { $not: insensitiveTrimmedRegexOperator(address) } },
Expand Down Expand Up @@ -686,8 +689,9 @@ export const configureUsersModule = async (moduleInput: ModuleInput<UserSettings
},

async setUsername(userId: string, username: string) {
if (!(await userSettings.validateUsername(username))) {
throw new Error(`Username ${username} is invalid`, { cause: 'USERNAME_INVALID' });
const usernameResult = await userSettings.validateUsername(username);
if (!usernameResult.valid) {
throw new Error(`Username ${username} is invalid`, { cause: usernameResult.reason });
}
const user = await Users.findOneAndUpdate(
{ _id: userId },
Expand Down
83 changes: 83 additions & 0 deletions packages/core-users/src/users-settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it, before } from 'node:test';
import assert from 'node:assert';
import { userSettings } from './users-settings.ts';

const makeDb = (count: number) =>
({
collection: () => ({
countDocuments: async () => count,
}),
}) as any;

describe('defaultValidateEmail', () => {
describe('format validation', () => {
before(() => {
userSettings.configureSettings({}, makeDb(0));
});

it('returns EMAIL_FORMAT_INVALID when email has no @', async () => {
const result = await userSettings.validateEmail('notanemail');
assert.deepStrictEqual(result, { valid: false, reason: 'EMAIL_FORMAT_INVALID' });
});

it('returns EMAIL_FORMAT_INVALID for empty string', async () => {
const result = await userSettings.validateEmail('');
assert.deepStrictEqual(result, { valid: false, reason: 'EMAIL_FORMAT_INVALID' });
});

it('returns valid: true for a correctly formatted new email', async () => {
const result = await userSettings.validateEmail('new@example.com');
assert.deepStrictEqual(result, { valid: true });
});
});

describe('duplicate detection', () => {
before(() => {
userSettings.configureSettings({}, makeDb(1));
});

it('returns EMAIL_ALREADY_EXISTS when email is taken', async () => {
const result = await userSettings.validateEmail('existing@example.com');
assert.deepStrictEqual(result, { valid: false, reason: 'EMAIL_ALREADY_EXISTS' });
});
});
});

describe('defaultValidateUsername', () => {
describe('length validation', () => {
before(() => {
userSettings.configureSettings({}, makeDb(0));
});

it('returns USERNAME_TOO_SHORT for a 2-character username', async () => {
const result = await userSettings.validateUsername('ab');
assert.deepStrictEqual(result, { valid: false, reason: 'USERNAME_TOO_SHORT' });
});

it('returns USERNAME_TOO_SHORT for empty string', async () => {
const result = await userSettings.validateUsername('');
assert.deepStrictEqual(result, { valid: false, reason: 'USERNAME_TOO_SHORT' });
});

it('returns valid: true for a username of exactly 3 characters', async () => {
const result = await userSettings.validateUsername('abc');
assert.deepStrictEqual(result, { valid: true });
});

it('returns valid: true for a longer valid username', async () => {
const result = await userSettings.validateUsername('newuser');
assert.deepStrictEqual(result, { valid: true });
});
});

describe('duplicate detection', () => {
before(() => {
userSettings.configureSettings({}, makeDb(1));
});

it('returns USERNAME_ALREADY_EXISTS when username is taken', async () => {
const result = await userSettings.validateUsername('existinguser');
assert.deepStrictEqual(result, { valid: false, reason: 'USERNAME_ALREADY_EXISTS' });
});
});
});
31 changes: 18 additions & 13 deletions packages/core-users/src/users-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,25 @@ export const UserAccountAction = {
} as const;

export type UserAccountAction = (typeof UserAccountAction)[keyof typeof UserAccountAction];
export type ValidationResult = { valid: true } | { valid: false; reason: string };

export interface UserSettings {
mergeUserCartsOnLogin: boolean;
autoMessagingAfterUserCreation: boolean;
earliestValidTokenDate: (
type: typeof UserAccountAction.VERIFY_EMAIL | typeof UserAccountAction.RESET_PASSWORD,
) => Date;
validateEmail: (email: string) => Promise<boolean>;
validateUsername: (username: string) => Promise<boolean>;
validateEmail: (email: string) => Promise<ValidationResult>;
validateUsername: (username: string) => Promise<ValidationResult>;
validateNewUser: (user: UserRegistrationData) => Promise<UserRegistrationData>;
validatePassword: (password: string) => Promise<boolean>;
configureSettings: (options: UserSettingsOptions, db: mongodb.Db) => void;
}

export type UserSettingsOptions = Omit<Partial<UserSettings>, 'configureSettings'>;
export type UserSettingsOptions = Omit<Partial<UserSettings>, 'configureSettings' | 'validateEmail' | 'validateUsername'> & {
validateEmail?: (email: string) => Promise<ValidationResult>;
validateUsername?: (username: string) => Promise<ValidationResult>;
};

const defaultAutoMessagingAfterUserCreation = true;
const defaultMergeUserCartsOnLogin = true;
Expand Down Expand Up @@ -56,8 +61,8 @@ export const userSettings: UserSettings = {
mergeUserCartsOnLogin: defaultMergeUserCartsOnLogin,
earliestValidTokenDate: defaultEarliestValidTokenDate,
validateNewUser: defaultValidateNewUser,
validateEmail: () => Promise.resolve(true),
validateUsername: () => Promise.resolve(true),
validateEmail: () => Promise.resolve({ valid: true } as ValidationResult),
validateUsername: () => Promise.resolve({ valid: true } as ValidationResult),
validatePassword: () => Promise.resolve(true),

configureSettings: (
Expand All @@ -72,21 +77,21 @@ export const userSettings: UserSettings = {
},
db: mongodb.Db,
) => {
const defaultValidateEmail = async (rawEmail: string) => {
if (!rawEmail?.includes?.('@')) return false;
const defaultValidateEmail = async (rawEmail: string): Promise<ValidationResult> => {
if (!rawEmail?.includes?.('@')) return { valid: false, reason: 'EMAIL_FORMAT_INVALID' };
const emailAlreadyExists = await db
.collection('users')
.countDocuments({ 'emails.address': insensitiveTrimmedRegexOperator(rawEmail) }, { limit: 1 });
if (emailAlreadyExists) return false;
return true;
if (emailAlreadyExists) return { valid: false, reason: 'EMAIL_ALREADY_EXISTS' };
return { valid: true };
};
const defaultValidateUsername = async (rawUsername: string) => {
if (rawUsername?.length < 3) return false;
const defaultValidateUsername = async (rawUsername: string): Promise<ValidationResult> => {
if (rawUsername?.length < 3) return { valid: false, reason: 'USERNAME_TOO_SHORT' };
const usernameAlreadyExists = await db
.collection('users')
.countDocuments({ username: insensitiveTrimmedRegexOperator(rawUsername) }, { limit: 1 });
if (usernameAlreadyExists) return false;
return true;
if (usernameAlreadyExists) return { valid: false, reason: 'USERNAME_ALREADY_EXISTS' };
return { valid: true };
};

userSettings.mergeUserCartsOnLogin = mergeUserCartsOnLogin ?? defaultMergeUserCartsOnLogin;
Expand Down