diff --git a/src/routes/auth/change-email.ts b/src/routes/auth/change-email.ts new file mode 100644 index 0000000..5b1f0b0 --- /dev/null +++ b/src/routes/auth/change-email.ts @@ -0,0 +1,141 @@ +import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; + +import { + buildPreValidation, + getResponseStructureSchema, +} from '@/routes/schema-helpers'; + +import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; + +import {capitalizeFirstLetter} from '@/utils/string'; + +export function registerEmailChangeRoute( + app: FastifyInstance, + config: AppConfig, +): void { + const {models} = config.data; + + const upConfig = config.authentication!.provider + .config as UpAuthProviderConfig; + const {model, idField, usernameField, isVerifiedField} = upConfig.userModel; + + const authModelConfig = models[model]; + + if (!authModelConfig) return; + + const apiIdentifier = `auth.${model}.all.emailChange`; + + if (config.apis?.[apiIdentifier]?.enabled === false) return; + + const schema: Record = generateSchema(usernameField, model); + + app.patch( + '/auth/user/email', + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, true, ['auth']), + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = request.user as Record; + const userId = userPayload.id; + + if (!userId) { + return reply + .status(401) + .send( + app.buildResponse(401, 'User ID missing in token payload', null), + ); + } + + const body = request.body as Record; + const newEmail = body[usernameField]; + + /* c8 ignore start */ + if (!newEmail) { + return reply + .status(400) + .send(app.buildResponse(400, `${usernameField} is required`, null)); + } + /* c8 ignore stop */ + + let tx; + try { + tx = await app.db.beginTransaction(); + + const selectQuery = `SELECT * FROM "${model}" WHERE "${idField}" = $1 LIMIT 1;`; + const res = await tx.query(selectQuery, [userId]); + + if (res.rows.length === 0) { + throw Object.assign(new Error('User not found'), { + statusCode: 404, + body: app.buildResponse(404, 'User not found', null), + }); + } + + const setClauses = [`"${usernameField}" = $1`]; + const values: unknown[] = [newEmail]; + + if (isVerifiedField) { + setClauses.push(`"${isVerifiedField}" = $2`); + values.push(false); + } + + values.push(userId); + const paramIndex = setClauses.length + 1; + const updateQuery = `UPDATE "${model}" SET ${setClauses.join(', ')} WHERE "${idField}" = $${paramIndex};`; + await tx.query(updateQuery, values); + + await tx.commit(); + + const ulid = await app.otp.sendOTPForVerification(newEmail); + + return reply.status(200).send( + app.buildResponse(200, 'Email updated. OTP sent to your new email.', { + requiresMfa: true, + ulid, + }), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); +} + +function generateSchema(usernameField: string, model: string) { + const bodySchema = { + type: 'object', + required: [usernameField], + properties: { + [usernameField]: {type: 'string', description: 'The new email address'}, + }, + additionalProperties: false, + }; + + const dataSchema = { + type: 'object', + properties: { + requiresMfa: { + type: 'boolean', + description: 'Indicates MFA is required', + }, + ulid: {type: 'string', description: 'OTP verification ULID'}, + }, + }; + + const responseSchema = getResponseStructureSchema([200], dataSchema); + + const schema: Record = { + summary: `Change email for ${capitalizeFirstLetter(model)}`, + description: `Updates the email address for the authenticated user in the "${model}" table and sends an OTP to the new email.`, + tags: [capitalizeFirstLetter(model), 'Auth', 'Profile'], + body: bodySchema, + response: responseSchema, + security: [{bearerAuth: []}], + }; + return schema; +} diff --git a/src/routes/auth/delete-me.ts b/src/routes/auth/delete-me.ts new file mode 100644 index 0000000..1cc51a0 --- /dev/null +++ b/src/routes/auth/delete-me.ts @@ -0,0 +1,102 @@ +import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; + +import { + buildPreValidation, + getResponseStructureSchema, +} from '@/routes/schema-helpers'; + +import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; + +import {capitalizeFirstLetter} from '@/utils/string'; + +export function registerDeleteMeRoute( + app: FastifyInstance, + config: AppConfig, +): void { + const {models} = config.data; + + const {model, idField} = ( + config.authentication!.provider.config as UpAuthProviderConfig + ).userModel; + + const authModelConfig = models[model]; + + if (!authModelConfig) return; + + const apiIdentifier = `auth.${model}.all.deleteMe`; + + if (config.apis?.[apiIdentifier]?.enabled === false) return; + + const schema: Record = generateSchema(model); + + app.delete( + '/auth/user/me', + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, true, ['auth']), + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = request.user as Record; + const userId = userPayload.id; + + if (!userId) { + return reply + .status(401) + .send( + app.buildResponse(401, 'User ID missing in token payload', null), + ); + } + + const selectQuery = `SELECT * FROM "${model}" WHERE "${idField}" = $1 LIMIT 1;`; + + let tx; + try { + tx = await app.db.beginTransaction(); + + const res = await tx.query(selectQuery, [userId]); + + if (res.rows.length === 0) { + throw Object.assign(new Error('User not found'), { + statusCode: 404, + body: app.buildResponse(404, 'User not found', null), + }); + } + + const deleteQuery = `DELETE FROM "${model}" WHERE "${idField}" = $1;`; + await tx.query(deleteQuery, [userId]); + + await tx.commit(); + + return reply.status(200).send( + app.buildResponse(200, 'User profile deleted successfully', { + success: true, + }), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); +} + +function generateSchema(model: string) { + const responseSchema = getResponseStructureSchema([200], { + type: 'object', + properties: { + success: {type: 'boolean'}, + }, + }); + + const schema: Record = { + summary: `Delete authenticated user profile for ${capitalizeFirstLetter(model)}`, + description: `Permanently deletes the currently authenticated user from the "${model}" table.`, + tags: [capitalizeFirstLetter(model), 'Auth', 'Profile'], + response: responseSchema, + security: [{bearerAuth: []}], + }; + return schema; +} diff --git a/src/routes/auth/edit-me.ts b/src/routes/auth/edit-me.ts new file mode 100644 index 0000000..674da69 --- /dev/null +++ b/src/routes/auth/edit-me.ts @@ -0,0 +1,179 @@ +import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; + +import { + buildPreValidation, + getResponseStructureSchema, + mapDataTypeToJsonSchema, +} from '@/routes/schema-helpers'; + +import { + AppConfig, + ModelBody, + ModelConfig, + UpAuthProviderConfig, +} from '@/interfaces/config'; + +import {capitalizeFirstLetter} from '@/utils/string'; + +export function registerEditMeRoute( + app: FastifyInstance, + config: AppConfig, +): void { + const {models} = config.data; + + const authConfig = ( + config.authentication!.provider.config as UpAuthProviderConfig + ).userModel; + const {model, idField, usernameField, passwordField, isVerifiedField} = + authConfig; + + const authModelConfig = models[model]; + + if (!authModelConfig) return; + + const apiIdentifier = `auth.${model}.all.editMe`; + + if (config.apis?.[apiIdentifier]?.enabled === false) return; + + const schema: Record = generateSchema( + authModelConfig, + model, + idField, + usernameField, + passwordField, + isVerifiedField, + ); + + app.patch( + '/auth/user/me', + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, true, ['auth']), + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = request.user as Record; + const userId = userPayload.id; + + if (!userId) { + return reply + .status(401) + .send( + app.buildResponse(401, 'User ID missing in token payload', null), + ); + } + + const body = request.body as ModelBody; + + const protectedFields = new Set( + [idField, usernameField, passwordField, isVerifiedField].filter( + Boolean, + ), + ); + const editableKeys = Object.keys(body).filter( + k => !protectedFields.has(k), + ); + + if (editableKeys.length === 0) { + return reply + .status(400) + .send(app.buildResponse(400, 'No editable fields provided', null)); + } + + const selectQuery = `SELECT * FROM "${model}" WHERE "${idField}" = $1 LIMIT 1;`; + + let tx; + try { + tx = await app.db.beginTransaction(); + + const res = await tx.query(selectQuery, [userId]); + + if (res.rows.length === 0) { + throw Object.assign(new Error('User not found'), { + statusCode: 404, + body: app.buildResponse(404, 'User not found', null), + }); + } + + const setClauses: string[] = []; + const values: unknown[] = []; + let paramIndex = 1; + + for (const key of editableKeys) { + setClauses.push(`"${key}" = $${paramIndex++}`); + values.push(body[key]); + } + + values.push(userId); + const updateQuery = `UPDATE "${model}" SET ${setClauses.join(', ')} WHERE "${idField}" = $${paramIndex};`; + await tx.query(updateQuery, values); + + await tx.commit(); + + const updatedFields: ModelBody = {}; + for (const key of editableKeys) { + updatedFields[key] = body[key]; + } + + return reply + .status(200) + .send( + app.buildResponse( + 200, + 'User profile updated successfully', + updatedFields, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); +} + +function generateSchema( + authModelConfig: ModelConfig, + model: string, + idField: string, + usernameField: string, + passwordField: string, + isVerifiedField?: string, +) { + const protectedFields = new Set( + [idField, usernameField, passwordField, isVerifiedField].filter(Boolean), + ); + + const bodyProperties: Record = {}; + for (const [fieldName, field] of Object.entries(authModelConfig.fields)) { + if (!protectedFields.has(fieldName)) { + bodyProperties[fieldName] = { + ...mapDataTypeToJsonSchema(field.type), + description: `New value for ${fieldName}`, + }; + } + } + + const bodySchema = { + type: 'object', + properties: bodyProperties, + additionalProperties: false, + }; + + const responseSchema = getResponseStructureSchema([200], { + type: 'object', + properties: bodyProperties, + }); + + const schema: Record = { + summary: `Edit authenticated user profile for ${capitalizeFirstLetter(model)}`, + description: `Updates the profile of the currently authenticated user in the "${model}" table. Cannot update ${idField}, ${usernameField}, ${passwordField}, or ${isVerifiedField}.`, + tags: [capitalizeFirstLetter(model), 'Auth', 'Profile'], + body: bodySchema, + response: responseSchema, + security: [{bearerAuth: []}], + }; + return schema; +} diff --git a/src/routes/auth/me.ts b/src/routes/auth/me.ts new file mode 100644 index 0000000..0b7200b --- /dev/null +++ b/src/routes/auth/me.ts @@ -0,0 +1,86 @@ +import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; + +import { + buildPreValidation, + generateJSONValidationSchema, + getResponseStructureSchema, +} from '@/routes/schema-helpers'; + +import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; + +import {capitalizeFirstLetter} from '@/utils/string'; + +export function registerMeRoute(app: FastifyInstance, config: AppConfig): void { + const {models} = config.data; + + const {model, idField} = ( + config.authentication!.provider.config as UpAuthProviderConfig + ).userModel; + + const authModelConfig = models[model]; + + if (!authModelConfig) return; + + const apiIdentifier = `auth.${model}.all.me`; + + if (config.apis?.[apiIdentifier]?.enabled === false) return; + + const schema: Record = generateSchema(model, config); + + app.get( + '/auth/user/me', + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, true, ['auth']), + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const userPayload = request.user as Record; + const userId = userPayload.id; + + if (!userId) { + return reply + .status(401) + .send( + app.buildResponse(401, 'User ID missing in token payload', null), + ); + } + + const query = `SELECT * FROM "${model}" WHERE "${idField}" = $1 LIMIT 1;`; + const res = await app.db.query(query, [userId]); + + if (res.rows.length === 0) { + return reply + .status(404) + .send(app.buildResponse(404, 'User not found', null)); + } + + const user = res.rows[0]; + + return reply + .status(200) + .send( + app.buildResponse(200, 'User profile retrieved successfully', user), + ); + }, + ); +} + +function generateSchema(model: string, config: AppConfig) { + const authModelConfig = config.data.models[model]; + + const dataSchema = authModelConfig + ? generateJSONValidationSchema(authModelConfig) + : {type: 'object', additionalProperties: true}; + + const responseSchema = getResponseStructureSchema([200], dataSchema); + + const schema: Record = { + summary: `Get authenticated user profile for ${capitalizeFirstLetter(model)}`, + description: `Returns the profile of the currently authenticated user from the "${model}" table.`, + tags: [capitalizeFirstLetter(model), 'Auth', 'Profile'], + response: responseSchema, + security: [{bearerAuth: []}], + }; + return schema; +} diff --git a/src/routes/auth/otp-verify.ts b/src/routes/auth/otp-verify.ts index 9c8f422..624b309 100644 --- a/src/routes/auth/otp-verify.ts +++ b/src/routes/auth/otp-verify.ts @@ -11,7 +11,7 @@ function registerOtpVerifyBase( app: FastifyInstance, config: AppConfig, path: string, - action: 'login' | 'registration' | 'forgot-password', + action: 'login' | 'register' | 'forgot-password', ): void { const {models} = config.data; @@ -83,7 +83,7 @@ function registerOtpVerifyBase( }); } - if (action === 'registration') { + if (action === 'register') { const isVerifiedField = upConfig.userModel.isVerifiedField; if (isVerifiedField) { const updateQuery = `UPDATE "${model}" SET "${isVerifiedField}" = true WHERE "${usernameField}" = $1;`; @@ -162,18 +162,13 @@ export function registerRegistrationOtpVerifyRoute( app: FastifyInstance, config: AppConfig, ): void { - registerOtpVerifyBase( - app, - config, - '/auth/registration/verify/otp', - 'registration', - ); + registerOtpVerifyBase(app, config, '/auth/register/verify/otp', 'register'); } function generateSchema( usernameField: string, model: string, - action: 'login' | 'registration' | 'forgot-password', + action: 'login' | 'register' | 'forgot-password', ) { const isForgotPassword = action === 'forgot-password'; diff --git a/src/routes/auth/resend-otp.ts b/src/routes/auth/resend-otp.ts new file mode 100644 index 0000000..5d2a3ab --- /dev/null +++ b/src/routes/auth/resend-otp.ts @@ -0,0 +1,135 @@ +import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; + +import {getResponseStructureSchema} from '@/routes/schema-helpers'; + +import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; + +import {capitalizeFirstLetter} from '@/utils/string'; + +function registerResendOtpBase( + app: FastifyInstance, + config: AppConfig, + path: string, + action: 'login' | 'register' | 'forgot-password', +): void { + const {models} = config.data; + + const upConfig = config.authentication!.provider + .config as UpAuthProviderConfig; + const {model, usernameField} = upConfig.userModel; + + const authModelConfig = models[model]; + + if (!authModelConfig) return; + + const apiIdentifier = `auth.${model}.all.resend-otp-${action}`; + + if (config.apis?.[apiIdentifier]?.enabled === false) return; + + const schema: Record = generateSchema( + usernameField, + model, + action, + ); + + app.post( + path, + { + schema, + config: {apiIdentifier}, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const {[usernameField]: username} = request.body as Record< + string, + string + >; + + /* c8 ignore start */ + if (!username) { + return reply + .status(400) + .send(app.buildResponse(400, `${usernameField} is required`, null)); + } + /* c8 ignore stop */ + + const query = `SELECT * FROM "${model}" WHERE "${usernameField}" = $1 LIMIT 1;`; + const res = await app.db.query(query, [String(username)]); + + if (res.rows.length === 0) { + return reply + .status(404) + .send(app.buildResponse(404, 'User not found', null)); + } + + const user = res.rows[0] as Record; + const userEmail = String(user[usernameField]); + const ulid = await app.otp.sendOTPForVerification(userEmail); + + return reply.status(200).send( + app.buildResponse(200, 'OTP resent to your email.', { + requiresMfa: true, + ulid, + }), + ); + }, + ); +} + +export function registerLoginResendOtpRoute( + app: FastifyInstance, + config: AppConfig, +): void { + registerResendOtpBase(app, config, '/auth/login/resend/otp', 'login'); +} + +export function registerRegistrationResendOtpRoute( + app: FastifyInstance, + config: AppConfig, +): void { + registerResendOtpBase(app, config, '/auth/register/resend/otp', 'register'); +} + +export function registerForgotPasswordResendOtpRoute( + app: FastifyInstance, + config: AppConfig, +): void { + registerResendOtpBase( + app, + config, + '/auth/forgot-password/resend/otp', + 'forgot-password', + ); +} + +function generateSchema(usernameField: string, model: string, action: string) { + const bodySchema = { + type: 'object', + required: [usernameField], + properties: { + [usernameField]: {type: 'string', description: 'The user identifier'}, + }, + additionalProperties: false, + }; + + const dataSchema = { + type: 'object', + properties: { + requiresMfa: { + type: 'boolean', + description: 'Indicates MFA is required', + }, + ulid: {type: 'string', description: 'OTP verification ULID'}, + }, + }; + + const responseSchema = getResponseStructureSchema([200], dataSchema); + + const schema: Record = { + summary: `Resend OTP for ${capitalizeFirstLetter(model)} ${action}`, + description: `Resends an OTP to the user's email for ${action}.`, + tags: [capitalizeFirstLetter(model), 'Auth', 'OTP'], + body: bodySchema, + response: responseSchema, + }; + return schema; +} diff --git a/src/routes/models/delete.ts b/src/routes/models/delete.ts index 2e313d0..a90330e 100644 --- a/src/routes/models/delete.ts +++ b/src/routes/models/delete.ts @@ -5,6 +5,7 @@ import { buildSecurityArray, getResponseStructureSchema, mapDataTypeToJsonSchema, + shouldApiBeEnabled, } from '@/routes/schema-helpers'; import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; @@ -25,7 +26,7 @@ export function registerDeleteRoutes( for (const [fieldName, field] of deletableFields) { const apiIdentifier = `model.${modelName}.${fieldName}.delete`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/models/edit.ts b/src/routes/models/edit.ts index 2ffb997..7ad7b61 100644 --- a/src/routes/models/edit.ts +++ b/src/routes/models/edit.ts @@ -7,6 +7,7 @@ import { buildSecurityArray, getResponseStructureSchema, mapDataTypeToJsonSchema, + shouldApiBeEnabled, } from '@/routes/schema-helpers'; import {AppConfig, ModelBody} from '@/interfaces/config'; @@ -27,7 +28,7 @@ export function registerEditRoutes( for (const [fieldName, field] of editableFields) { const apiIdentifier = `model.${modelName}.${fieldName}.edit`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/models/get-all.ts b/src/routes/models/get-all.ts index bf50022..f17f3a9 100644 --- a/src/routes/models/get-all.ts +++ b/src/routes/models/get-all.ts @@ -7,6 +7,7 @@ import { buildSecurityArray, generateJSONValidationSchema, getResponseStructureSchema, + shouldApiBeEnabled, } from '@/routes/schema-helpers'; import {AppConfig, ModelConfig} from '@/interfaces/config'; @@ -22,7 +23,7 @@ export function registerGetAllRoutes( for (const [modelName, model] of Object.entries(models)) { const apiIdentifier = `model.${modelName}.all.getAll`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/models/index-route.ts b/src/routes/models/index-route.ts index d114601..2b5c6f7 100644 --- a/src/routes/models/index-route.ts +++ b/src/routes/models/index-route.ts @@ -8,6 +8,7 @@ import { generateJSONValidationSchema, getResponseStructureSchema, mapDataTypeToJsonSchema, + shouldApiBeEnabled, } from '@/routes/schema-helpers'; import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; @@ -28,7 +29,7 @@ export function registerIndexRoutes( for (const [fieldName, field] of indexFields) { const apiIdentifier = `model.${modelName}.${fieldName}.index`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/models/post.ts b/src/routes/models/post.ts index 517c969..18f8594 100644 --- a/src/routes/models/post.ts +++ b/src/routes/models/post.ts @@ -5,6 +5,7 @@ import { buildSecurityArray, generateJSONValidationSchema, getResponseStructureSchema, + shouldApiBeEnabled, stripAdditionalPostFields, } from '@/routes/schema-helpers'; @@ -21,7 +22,7 @@ export function registerPostRoutes( for (const [modelName, model] of Object.entries(models)) { const apiIdentifier = `model.${modelName}.all.insert`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/models/search.ts b/src/routes/models/search.ts index 6ac4d23..d76791c 100644 --- a/src/routes/models/search.ts +++ b/src/routes/models/search.ts @@ -7,6 +7,7 @@ import { buildSecurityArray, generateJSONValidationSchema, getResponseStructureSchema, + shouldApiBeEnabled, } from '@/routes/schema-helpers'; import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; @@ -27,7 +28,7 @@ export function registerSearchRoutes( for (const [fieldName, field] of searchableFields) { const apiIdentifier = `model.${modelName}.${fieldName}.search`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; const authorization = config.apis?.[apiIdentifier]?.authorization ?? diff --git a/src/routes/schema-helpers.ts b/src/routes/schema-helpers.ts index bf8a216..036cf9e 100644 --- a/src/routes/schema-helpers.ts +++ b/src/routes/schema-helpers.ts @@ -6,6 +6,7 @@ import { ModelBody, ModelConfig, ModelFieldConfig, + UpAuthProviderConfig, } from '@/interfaces/config'; import {normalizeSchemaForAjv} from '@/utils/schema'; @@ -241,6 +242,26 @@ export function stripAdditionalPostFields( return filtered; } +export function shouldApiBeEnabled( + config: AppConfig, + apiIdentifier: string, + modelName: string, +): boolean { + const authModel = + config.authentication?.provider?.type === 'up-auth' + ? (config.authentication.provider.config as UpAuthProviderConfig) + .userModel.model + : null; + + const apiEnabled = config.apis?.[apiIdentifier]?.enabled; + + if (apiEnabled === false) return false; + + if (modelName === authModel) return apiEnabled === true; + + return true; +} + export const getResponseStructureSchema = ( codes: number[], dataSchema: Record, diff --git a/src/server.ts b/src/server.ts index 4eaec7a..3ca3dd3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,18 +18,27 @@ import swaggerPlugin from '@/plugin/swagger'; import webhookPlugin from '@/plugin/webhook'; import {registerRoutes} from '@/routes'; +import {registerEmailChangeRoute} from '@/routes/auth/change-email'; import {registerChangePasswordRoute} from '@/routes/auth/change-password'; +import {registerDeleteMeRoute} from '@/routes/auth/delete-me'; +import {registerEditMeRoute} from '@/routes/auth/edit-me'; import {registerForgotPasswordRoute} from '@/routes/auth/forgot-password'; import {registerLoginRoute} from '@/routes/auth/login'; +import {registerMeRoute} from '@/routes/auth/me'; import { registerForgotPasswordOtpVerifyRoute, registerLoginOtpVerifyRoute, registerRegistrationOtpVerifyRoute, } from '@/routes/auth/otp-verify'; import {registerRegistrationRoute} from '@/routes/auth/registration'; +import { + registerForgotPasswordResendOtpRoute, + registerLoginResendOtpRoute, + registerRegistrationResendOtpRoute, +} from '@/routes/auth/resend-otp'; import {Mode} from '@/interfaces'; -import {AppConfig} from '@/interfaces/config'; +import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; import {validateConfig} from '@/validators/config'; import {RouteInfo} from '@/utils/welcome'; @@ -127,13 +136,32 @@ export async function startServer( config.authentication?.enabled && config.authentication?.provider?.type === 'up-auth' ) { + const upConfig = config.authentication.provider + .config as UpAuthProviderConfig; + registerRegistrationRoute(app, config); registerLoginRoute(app, config); registerChangePasswordRoute(app, config); - registerForgotPasswordRoute(app, config); - registerLoginOtpVerifyRoute(app, config); - registerRegistrationOtpVerifyRoute(app, config); - registerForgotPasswordOtpVerifyRoute(app, config); + registerMeRoute(app, config); + registerDeleteMeRoute(app, config); + registerEditMeRoute(app, config); + + if (config.integrations?.email) { + registerForgotPasswordRoute(app, config); + registerForgotPasswordOtpVerifyRoute(app, config); + registerForgotPasswordResendOtpRoute(app, config); + } + + if (upConfig.mfaRequired) { + registerLoginOtpVerifyRoute(app, config); + registerLoginResendOtpRoute(app, config); + } + + if (upConfig.userModel.isVerifiedField) { + registerRegistrationOtpVerifyRoute(app, config); + registerRegistrationResendOtpRoute(app, config); + registerEmailChangeRoute(app, config); + } } // Global error handler diff --git a/tests/routes/change-email.test.ts b/tests/routes/change-email.test.ts new file mode 100644 index 0000000..55ee6d3 --- /dev/null +++ b/tests/routes/change-email.test.ts @@ -0,0 +1,431 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import bcrypt from 'bcrypt'; +import Fastify, {FastifyInstance} from 'fastify'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import authPlugin from '@/plugin/auth'; +import databasePlugin from '@/plugin/database'; +import otpPlugin from '@/plugin/otp'; +import responsePlugin from '@/plugin/response'; + +import {registerEmailChangeRoute} from '@/routes/auth/change-email'; + +import { + AppConfig, + AuthenticationConfig, + DatabaseConfig, + ModelConfig, +} from '@/interfaces/config'; + +import { + pgClientQueryMock, + pgConnectMock, + pgQueryMock, +} from '@tests/helpers/db-mocks'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const authModels: Record = { + users: { + fields: { + id: {type: 'integer', primaryKey: true}, + email: {type: 'string', nullable: false}, + password: {type: 'string', nullable: false}, + is_active: {type: 'boolean', default: false}, + }, + }, +}; + +const upAuthConfig: AuthenticationConfig = { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + isVerifiedField: 'is_active', + }, + }, + }, +}; + +const pgConfig: DatabaseConfig = { + engine: 'postgres', + connection: { + url: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, +}; + +// --------------------------------------------------------------------------- +// Helper: create a Fastify instance with the change-email route wired up +// --------------------------------------------------------------------------- + +async function createChangeEmailApp( + authentication: AuthenticationConfig, + models: Record = authModels, + dbConfig: DatabaseConfig = pgConfig, + apis?: Record, +): Promise { + const app = Fastify(); + const config: AppConfig = { + application: {name: 'Test App', logLevel: 'error'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', description: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: {database: dbConfig}, + data: {models}, + authentication, + ...(apis ? {apis} : {}), + }; + app.appConfig = config; + + const cacheStorage = new Map(); + (app as any).cache = { + get: vi.fn(async (key: string) => { + const item = cacheStorage.get(key); + if (!item) return null; + return item.value; + }), + set: vi.fn(async (key: string, value: unknown, ttlSeconds?: number) => { + const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined; + cacheStorage.set(key, {value, expiry}); + }), + delete: vi.fn(async (key: string) => { + cacheStorage.delete(key); + }), + }; + (app as any).communicate = { + sendEmail: vi.fn(async () => {}), + }; + + await app.register(databasePlugin); + await app.register(responsePlugin); + await app.register(authPlugin); + await app.register(otpPlugin); + + if (authentication?.enabled && authentication.provider?.type === 'up-auth') { + registerEmailChangeRoute(app, config); + } + await app.ready(); + return app; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PATCH /auth/user/email', () => { + beforeEach(() => { + pgQueryMock.mockClear(); + pgClientQueryMock.mockClear(); + vi.restoreAllMocks(); + }); + + describe('guard conditions', () => { + test('should NOT register the route when enabled is false', async () => { + const authentication: AuthenticationConfig = { + ...upAuthConfig, + enabled: false, + }; + const app = await createChangeEmailApp(authentication); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + + test('should NOT register the route when model is not found in models', async () => { + const app = await createChangeEmailApp(upAuthConfig, {}); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + + test('should NOT register the route when the API is disabled via apis config', async () => { + const app = await createChangeEmailApp( + upAuthConfig, + authModels, + pgConfig, + { + 'auth.users.all.emailChange': {enabled: false}, + }, + ); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + }); + + describe('authentication', () => { + test('should return 401 if unauthenticated', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if token is invalid', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: { + authorization: 'Bearer invalidtoken', + }, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if user ID is missing from token payload', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const token = app.jwt.sign({email: 'alice@example.com'}); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe('User ID missing in token payload'); + await app.close(); + }); + }); + + describe('happy path', () => { + test('should return 200, update email and isVerifiedField, and send OTP', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + + // Mock transaction: BEGIN, SELECT (user exists), UPDATE, COMMIT + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + // SELECT + rows: [ + { + id: 1, + email: 'alice@example.com', + password: 'hashed', + is_active: true, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {email: 'newalice@example.com'}, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.message).toBe('Email updated. OTP sent to your new email.'); + expect(body.data.requiresMfa).toBe(true); + expect(typeof body.data.ulid).toBe('string'); + expect(body.data.ulid.length).toBeGreaterThan(0); + + // Verify the UPDATE query + const updateCall = pgClientQueryMock.mock.calls.find( + (call: unknown[]) => + typeof call[0] === 'string' && (call[0] as string).includes('UPDATE'), + ); + expect(updateCall).toBeDefined(); + const updateQuery = (updateCall as [string, unknown[]])[0] as string; + expect(updateQuery).toContain('"email" = $1'); + expect(updateQuery).toContain('"is_active" = $2'); + expect(updateQuery).toContain('WHERE "id" = $3'); + + // Verify the UPDATE values + const updateValues = (updateCall as [string, unknown[]])[1] as unknown[]; + expect(updateValues[0]).toBe('newalice@example.com'); + expect(updateValues[1]).toBe(false); + expect(updateValues[2]).toBe(1); + + await app.close(); + }); + + test('should return 200 without isVerifiedField when not configured', async () => { + const authWithoutVerified: AuthenticationConfig = { + ...upAuthConfig, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + }, + }, + }; + const app = await createChangeEmailApp(authWithoutVerified); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + // SELECT + rows: [ + { + id: 1, + email: 'alice@example.com', + password: 'hashed', + is_active: true, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(200); + + // Verify the UPDATE query does NOT include isVerifiedField + const updateCall = pgClientQueryMock.mock.calls.find( + (call: unknown[]) => + typeof call[0] === 'string' && (call[0] as string).includes('UPDATE'), + ); + expect(updateCall).toBeDefined(); + const updateQuery = (updateCall as [string, unknown[]])[0] as string; + expect(updateQuery).toContain('"email" = $1'); + expect(updateQuery).not.toContain('"is_active"'); + expect(updateQuery).toContain('WHERE "id" = $2'); + + await app.close(); + }); + }); + + describe('unhappy path', () => { + test('should return 404 if user is not found in database', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const token = app.jwt.sign({id: 999, email: 'ghost@example.com'}); + + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], rowCount: 0}); // SELECT (empty) + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + await app.close(); + }); + + test('should handle rollback failure on DB error', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockRejectedValueOnce(new Error('Query failed')) // SELECT fails + .mockRejectedValueOnce(new Error('Rollback failed')); // ROLLBACK fails + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: {authorization: `Bearer ${token}`}, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(500); + await app.close(); + }); + + test('should handle beginTransaction failure', async () => { + const app = await createChangeEmailApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + pgConnectMock.mockRejectedValueOnce(new Error('Connection failed')); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/email', + headers: {authorization: `Bearer ${token}`}, + payload: {email: 'new@example.com'}, + }); + + expect(response.statusCode).toBe(500); + await app.close(); + }); + }); +}); diff --git a/tests/routes/delete-me.test.ts b/tests/routes/delete-me.test.ts new file mode 100644 index 0000000..872cd7a --- /dev/null +++ b/tests/routes/delete-me.test.ts @@ -0,0 +1,293 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import authPlugin from '@/plugin/auth'; +import databasePlugin from '@/plugin/database'; +import responsePlugin from '@/plugin/response'; + +import {registerDeleteMeRoute} from '@/routes/auth/delete-me'; + +import { + AppConfig, + AuthenticationConfig, + DatabaseConfig, + ModelConfig, +} from '@/interfaces/config'; + +import {pgClientQueryMock, pgQueryMock} from '@tests/helpers/db-mocks'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const authModels: Record = { + users: { + fields: { + id: {type: 'integer', primaryKey: true}, + email: {type: 'string', nullable: false}, + password: {type: 'string', nullable: false}, + }, + }, +}; + +const upAuthConfig: AuthenticationConfig = { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + }, + }, +}; + +const pgConfig: DatabaseConfig = { + engine: 'postgres', + connection: { + url: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, +}; + +// --------------------------------------------------------------------------- +// Helper: create a Fastify instance with the delete-me route wired up +// --------------------------------------------------------------------------- + +async function createDeleteMeApp( + authentication: AuthenticationConfig, + models: Record = authModels, + dbConfig: DatabaseConfig = pgConfig, + apis?: Record, +): Promise { + const app = Fastify(); + const config: AppConfig = { + application: {name: 'Test App', logLevel: 'error'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', description: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: {database: dbConfig}, + data: {models}, + authentication, + ...(apis ? {apis} : {}), + }; + app.appConfig = config; + await app.register(databasePlugin); + await app.register(responsePlugin); + await app.register(authPlugin); + + if (authentication?.enabled && authentication.provider?.type === 'up-auth') { + registerDeleteMeRoute(app, config); + } + await app.ready(); + return app; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('DELETE /auth/user/me', () => { + beforeEach(() => { + pgQueryMock.mockClear(); + pgClientQueryMock.mockClear(); + vi.restoreAllMocks(); + }); + + describe('guard conditions', () => { + test('should NOT register the route when enabled is false', async () => { + const authentication: AuthenticationConfig = { + ...upAuthConfig, + enabled: false, + }; + const app = await createDeleteMeApp(authentication); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + + test('should NOT register the route when model is not found in models', async () => { + const app = await createDeleteMeApp(upAuthConfig, {}); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + + test('should NOT register the route when the API is disabled via apis config', async () => { + const app = await createDeleteMeApp(upAuthConfig, authModels, pgConfig, { + 'auth.users.all.deleteMe': {enabled: false}, + }); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + }); + + describe('authentication', () => { + test('should return 401 if unauthenticated', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if token is invalid', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + headers: { + authorization: 'Bearer invalidtoken', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if user ID is missing from token payload', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const token = app.jwt.sign({email: 'alice@example.com'}); + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe('User ID missing in token payload'); + await app.close(); + }); + }); + + describe('happy path', () => { + test('should return 200 and delete the user when user exists', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + // Mock transaction: BEGIN, SELECT (user exists), DELETE, COMMIT + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + // SELECT + rows: [ + {id: 1, email: 'alice@example.com', password: 'hashed_password'}, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 1}) // DELETE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.message).toBe('User profile deleted successfully'); + expect(body.data.success).toBe(true); + + // Verify the SELECT query + expect(pgClientQueryMock).toHaveBeenCalledWith( + 'SELECT * FROM "users" WHERE "id" = $1 LIMIT 1;', + [1], + ); + // Verify the DELETE query + expect(pgClientQueryMock).toHaveBeenCalledWith( + 'DELETE FROM "users" WHERE "id" = $1;', + [1], + ); + + await app.close(); + }); + }); + + describe('unhappy path', () => { + test('should return 404 if user is not found in database', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 999, email: 'ghost@example.com'}); + + // Mock transaction: BEGIN, SELECT (no user), ROLLBACK + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], rowCount: 0}); // SELECT (empty) + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + await app.close(); + }); + + test('should handle rollback failure on DB error', async () => { + const app = await createDeleteMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockRejectedValueOnce(new Error('Query failed')) // SELECT fails + .mockRejectedValueOnce(new Error('Rollback failed')); // ROLLBACK fails + + const response = await app.inject({ + method: 'DELETE', + url: '/auth/user/me', + headers: {authorization: `Bearer ${token}`}, + }); + + expect(response.statusCode).toBe(500); + await app.close(); + }); + }); +}); diff --git a/tests/routes/delete.test.ts b/tests/routes/delete.test.ts index 8406a79..771335a 100644 --- a/tests/routes/delete.test.ts +++ b/tests/routes/delete.test.ts @@ -223,6 +223,7 @@ describe('test delete api', () => { describe('authentication', () => { const apisConfig = { 'model.users.id.delete': { + enabled: true, authorization: true, }, }; diff --git a/tests/routes/edit-me.test.ts b/tests/routes/edit-me.test.ts new file mode 100644 index 0000000..4680eac --- /dev/null +++ b/tests/routes/edit-me.test.ts @@ -0,0 +1,331 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import authPlugin from '@/plugin/auth'; +import databasePlugin from '@/plugin/database'; +import responsePlugin from '@/plugin/response'; + +import {registerEditMeRoute} from '@/routes/auth/edit-me'; + +import { + AppConfig, + AuthenticationConfig, + DatabaseConfig, + ModelConfig, +} from '@/interfaces/config'; + +import {pgClientQueryMock, pgQueryMock} from '@tests/helpers/db-mocks'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const authModels: Record = { + users: { + fields: { + id: {type: 'integer', primaryKey: true}, + email: {type: 'string', nullable: false}, + password: {type: 'string', nullable: false}, + name: {type: 'string', nullable: true}, + avatar: {type: 'string', nullable: true}, + }, + }, +}; + +const upAuthConfig: AuthenticationConfig = { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + }, + }, +}; + +const pgConfig: DatabaseConfig = { + engine: 'postgres', + connection: { + url: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, +}; + +// --------------------------------------------------------------------------- +// Helper: create a Fastify instance with the edit-me route wired up +// --------------------------------------------------------------------------- + +async function createEditMeApp( + authentication: AuthenticationConfig, + models: Record = authModels, + dbConfig: DatabaseConfig = pgConfig, + apis?: Record, +): Promise { + const app = Fastify(); + const config: AppConfig = { + application: {name: 'Test App', logLevel: 'error'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', description: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: {database: dbConfig}, + data: {models}, + authentication, + ...(apis ? {apis} : {}), + }; + app.appConfig = config; + await app.register(databasePlugin); + await app.register(responsePlugin); + await app.register(authPlugin); + + if (authentication?.enabled && authentication.provider?.type === 'up-auth') { + registerEditMeRoute(app, config); + } + await app.ready(); + return app; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('PATCH /auth/user/me', () => { + beforeEach(() => { + pgQueryMock.mockClear(); + pgClientQueryMock.mockClear(); + vi.restoreAllMocks(); + }); + + describe('guard conditions', () => { + test('should NOT register the route when enabled is false', async () => { + const authentication: AuthenticationConfig = { + ...upAuthConfig, + enabled: false, + }; + const app = await createEditMeApp(authentication); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + + test('should NOT register the route when model is not found in models', async () => { + const app = await createEditMeApp(upAuthConfig, {}); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + + test('should NOT register the route when the API is disabled via apis config', async () => { + const app = await createEditMeApp(upAuthConfig, authModels, pgConfig, { + 'auth.users.all.editMe': {enabled: false}, + }); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + }); + + describe('authentication', () => { + test('should return 401 if unauthenticated', async () => { + const app = await createEditMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if token is invalid', async () => { + const app = await createEditMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: { + authorization: 'Bearer invalidtoken', + }, + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if user ID is missing from token payload', async () => { + const app = await createEditMeApp(upAuthConfig); + + const token = app.jwt.sign({email: 'alice@example.com'}); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {name: 'New Name'}, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe('User ID missing in token payload'); + await app.close(); + }); + }); + + describe('happy path', () => { + test('should return 200 and update editable fields', async () => { + const app = await createEditMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + // Mock transaction: BEGIN, SELECT (user exists), UPDATE, COMMIT + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + // SELECT + rows: [ + { + id: 1, + email: 'alice@example.com', + password: 'hashed', + name: 'Alice', + avatar: null, + }, + ], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {name: 'Alice Smith', avatar: 'alice.png'}, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.message).toBe('User profile updated successfully'); + expect(body.data).toEqual({name: 'Alice Smith', avatar: 'alice.png'}); + + // Verify the UPDATE query excludes protected fields + const updateCall = pgClientQueryMock.mock.calls.find( + (call: unknown[]) => + typeof call[0] === 'string' && (call[0] as string).includes('UPDATE'), + ); + expect(updateCall).toBeDefined(); + const updateQuery = (updateCall as [string, unknown[]])[0] as string; + expect(updateQuery).toContain('"name" = $1'); + expect(updateQuery).toContain('"avatar" = $2'); + expect(updateQuery).not.toContain('SET "id"'); + expect(updateQuery).not.toContain('"email" ='); + expect(updateQuery).not.toContain('"password" ='); + + await app.close(); + }); + }); + + describe('unhappy path', () => { + test('should return 400 when body contains only protected fields', async () => { + const app = await createEditMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {id: 1, email: 'new@example.com', password: 'newpass'}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json().message).toBe('No editable fields provided'); + await app.close(); + }); + + test('should return 404 if user is not found in database', async () => { + const app = await createEditMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 999, email: 'ghost@example.com'}); + + // Mock transaction: BEGIN, SELECT (no user), ROLLBACK + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], rowCount: 0}); // SELECT (empty) + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + payload: {name: 'Ghost'}, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + await app.close(); + }); + + test('should handle rollback failure on DB error', async () => { + const app = await createEditMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockRejectedValueOnce(new Error('Query failed')) // SELECT fails + .mockRejectedValueOnce(new Error('Rollback failed')); // ROLLBACK fails + + const response = await app.inject({ + method: 'PATCH', + url: '/auth/user/me', + headers: {authorization: `Bearer ${token}`}, + payload: {name: 'Alice'}, + }); + + expect(response.statusCode).toBe(500); + await app.close(); + }); + }); +}); diff --git a/tests/routes/edit.test.ts b/tests/routes/edit.test.ts index 9a459c8..7460a55 100644 --- a/tests/routes/edit.test.ts +++ b/tests/routes/edit.test.ts @@ -585,6 +585,7 @@ describe('test edit api', () => { describe('authentication', () => { const apisConfig = { 'model.users.id.edit': { + enabled: true, authorization: true, }, }; diff --git a/tests/routes/get-all.test.ts b/tests/routes/get-all.test.ts index 90279ad..06ec87a 100644 --- a/tests/routes/get-all.test.ts +++ b/tests/routes/get-all.test.ts @@ -501,6 +501,7 @@ describe('test get-all api', () => { describe('authentication', () => { const apisConfig = { 'model.users.all.getAll': { + enabled: true, authorization: true, }, }; diff --git a/tests/routes/index-route.test.ts b/tests/routes/index-route.test.ts index 12c9ecb..e3b7fc4 100644 --- a/tests/routes/index-route.test.ts +++ b/tests/routes/index-route.test.ts @@ -640,6 +640,7 @@ describe('test index-route api', () => { describe('authentication', () => { const apisConfig = { 'model.users.id.index': { + enabled: true, authorization: true, }, }; diff --git a/tests/routes/me.test.ts b/tests/routes/me.test.ts new file mode 100644 index 0000000..afa152a --- /dev/null +++ b/tests/routes/me.test.ts @@ -0,0 +1,258 @@ +import Fastify, {FastifyInstance} from 'fastify'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import authPlugin from '@/plugin/auth'; +import databasePlugin from '@/plugin/database'; +import responsePlugin from '@/plugin/response'; + +import {registerMeRoute} from '@/routes/auth/me'; + +import { + AppConfig, + AuthenticationConfig, + DatabaseConfig, + ModelConfig, +} from '@/interfaces/config'; + +import {pgQueryMock} from '@tests/helpers/db-mocks'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const authModels: Record = { + users: { + fields: { + id: {type: 'integer', primaryKey: true}, + email: {type: 'string', nullable: false}, + password: {type: 'string', nullable: false}, + }, + }, +}; + +const upAuthConfig: AuthenticationConfig = { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + }, + }, +}; + +const pgConfig: DatabaseConfig = { + engine: 'postgres', + connection: { + url: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, +}; + +// --------------------------------------------------------------------------- +// Helper: create a Fastify instance with the me route wired up +// --------------------------------------------------------------------------- + +async function createMeApp( + authentication: AuthenticationConfig, + models: Record = authModels, + dbConfig: DatabaseConfig = pgConfig, + apis?: Record, +): Promise { + const app = Fastify(); + const config: AppConfig = { + application: {name: 'Test App', logLevel: 'error'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', description: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: {database: dbConfig}, + data: {models}, + authentication, + ...(apis ? {apis} : {}), + }; + app.appConfig = config; + await app.register(databasePlugin); + await app.register(responsePlugin); + await app.register(authPlugin); + + if (authentication?.enabled && authentication.provider?.type === 'up-auth') { + registerMeRoute(app, config); + } + await app.ready(); + return app; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('GET /auth/user/me', () => { + beforeEach(() => { + pgQueryMock.mockClear(); + vi.restoreAllMocks(); + }); + + describe('guard conditions', () => { + test('should NOT register the route when enabled is false', async () => { + const authentication: AuthenticationConfig = { + ...upAuthConfig, + enabled: false, + }; + const app = await createMeApp(authentication); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + + test('should NOT register the route when model is not found in models', async () => { + const app = await createMeApp(upAuthConfig, {}); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + + test('should NOT register the route when the API is disabled via apis config', async () => { + const app = await createMeApp(upAuthConfig, authModels, pgConfig, { + 'auth.users.all.me': {enabled: false}, + }); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + }); + + describe('authentication', () => { + test('should return 401 if unauthenticated', async () => { + const app = await createMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if token is invalid', async () => { + const app = await createMeApp(upAuthConfig); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + headers: { + authorization: 'Bearer invalidtoken', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe( + 'Invalid or expired authentication token', + ); + await app.close(); + }); + + test('should return 401 if user ID is missing from token payload', async () => { + const app = await createMeApp(upAuthConfig); + + const token = app.jwt.sign({email: 'alice@example.com'}); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().message).toBe('User ID missing in token payload'); + await app.close(); + }); + }); + + describe('happy path', () => { + test('should return 200 and user data when user exists', async () => { + const app = await createMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 1, email: 'alice@example.com'}); + + const mockUser = { + id: 1, + email: 'alice@example.com', + password: 'hashed_password', + }; + pgQueryMock.mockResolvedValueOnce({ + rows: [mockUser], + rowCount: 1, + }); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.message).toBe('User profile retrieved successfully'); + expect(body.data).toEqual(mockUser); + + await app.close(); + }); + }); + + describe('unhappy path', () => { + test('should return 404 if user is not found in database', async () => { + const app = await createMeApp(upAuthConfig); + + const token = app.jwt.sign({id: 999, email: 'ghost@example.com'}); + + pgQueryMock.mockResolvedValueOnce({ + rows: [], + rowCount: 0, + }); + + const response = await app.inject({ + method: 'GET', + url: '/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + await app.close(); + }); + }); +}); diff --git a/tests/routes/otp-verify.test.ts b/tests/routes/otp-verify.test.ts index 14d093d..eaf2310 100644 --- a/tests/routes/otp-verify.test.ts +++ b/tests/routes/otp-verify.test.ts @@ -334,7 +334,7 @@ describe('POST /auth/login/verify/otp', () => { }); }); -describe('POST /auth/registration/verify/otp', () => { +describe('POST /auth/register/verify/otp', () => { beforeEach(() => { pgQueryMock.mockClear(); pgClientQueryMock.mockClear(); @@ -351,7 +351,7 @@ describe('POST /auth/registration/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/registration/verify/otp', + url: '/auth/register/verify/otp', payload: {ulid: 'test-ulid', otp: '123456', email: 'test@example.com'}, }); @@ -383,7 +383,7 @@ describe('POST /auth/registration/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/registration/verify/otp', + url: '/auth/register/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -444,7 +444,7 @@ describe('POST /auth/registration/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/registration/verify/otp', + url: '/auth/register/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -475,7 +475,7 @@ describe('POST /auth/registration/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/registration/verify/otp', + url: '/auth/register/verify/otp', payload: { ulid: 'wrong-ulid', otp: '000000', @@ -493,7 +493,7 @@ describe('POST /auth/registration/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/registration/verify/otp', + url: '/auth/register/verify/otp', payload: {ulid: 'test-ulid'}, }); diff --git a/tests/routes/post.test.ts b/tests/routes/post.test.ts index 77bc4ea..b3de8c2 100644 --- a/tests/routes/post.test.ts +++ b/tests/routes/post.test.ts @@ -236,6 +236,7 @@ describe('test post api', () => { describe('authentication', () => { const apisConfig = { 'model.users.all.insert': { + enabled: true, authorization: true, }, }; diff --git a/tests/routes/resend-otp.test.ts b/tests/routes/resend-otp.test.ts new file mode 100644 index 0000000..cb7ca7d --- /dev/null +++ b/tests/routes/resend-otp.test.ts @@ -0,0 +1,266 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import Fastify, {FastifyInstance} from 'fastify'; +import {beforeEach, describe, expect, test, vi} from 'vitest'; + +import authPlugin from '@/plugin/auth'; +import databasePlugin from '@/plugin/database'; +import otpPlugin from '@/plugin/otp'; +import responsePlugin from '@/plugin/response'; + +import { + registerForgotPasswordResendOtpRoute, + registerLoginResendOtpRoute, + registerRegistrationResendOtpRoute, +} from '@/routes/auth/resend-otp'; + +import { + AppConfig, + AuthenticationConfig, + DatabaseConfig, + ModelConfig, +} from '@/interfaces/config'; + +import {pgQueryMock} from '@tests/helpers/db-mocks'; + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +const authModels: Record = { + users: { + fields: { + id: {type: 'integer', primaryKey: true}, + email: {type: 'string', nullable: false}, + password: {type: 'string', nullable: false}, + }, + }, +}; + +const upAuthConfig: AuthenticationConfig = { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + }, + }, +}; + +const pgConfig: DatabaseConfig = { + engine: 'postgres', + connection: { + url: 'postgresql://postgres:postgres@localhost:5432/postgres', + }, +}; + +// --------------------------------------------------------------------------- +// Helper: create a Fastify instance with a single resend-otp route wired up +// --------------------------------------------------------------------------- + +async function createResendOtpApp( + authentication: AuthenticationConfig, + action: 'login' | 'register' | 'forgot-password', + models: Record = authModels, + dbConfig: DatabaseConfig = pgConfig, + apis?: Record, +): Promise { + const app = Fastify(); + const config: AppConfig = { + application: {name: 'Test App', logLevel: 'error'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', description: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: {database: dbConfig}, + data: {models}, + authentication, + ...(apis ? {apis} : {}), + }; + app.appConfig = config; + + const cacheStorage = new Map(); + (app as any).cache = { + get: vi.fn(async (key: string) => { + const item = cacheStorage.get(key); + if (!item) return null; + return item.value; + }), + set: vi.fn(async (key: string, value: unknown, ttlSeconds?: number) => { + const expiry = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined; + cacheStorage.set(key, {value, expiry}); + }), + delete: vi.fn(async (key: string) => { + cacheStorage.delete(key); + }), + }; + (app as any).communicate = { + sendEmail: vi.fn(async () => {}), + }; + + await app.register(databasePlugin); + await app.register(responsePlugin); + await app.register(authPlugin); + await app.register(otpPlugin); + + if (authentication?.enabled && authentication.provider?.type === 'up-auth') { + if (action === 'login') { + registerLoginResendOtpRoute(app, config); + } else if (action === 'register') { + registerRegistrationResendOtpRoute(app, config); + } else { + registerForgotPasswordResendOtpRoute(app, config); + } + } + await app.ready(); + return app; +} + +function getPath(action: 'login' | 'register' | 'forgot-password'): string { + if (action === 'login') return '/auth/login/resend/otp'; + if (action === 'register') return '/auth/register/resend/otp'; + return '/auth/forgot-password/resend/otp'; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const actions: Array<'login' | 'register' | 'forgot-password'> = [ + 'login', + 'register', + 'forgot-password', +]; + +for (const action of actions) { + const path = getPath(action); + const displayPath = path.toUpperCase(); + + describe(`POST ${displayPath}`, () => { + beforeEach(() => { + pgQueryMock.mockClear(); + vi.restoreAllMocks(); + }); + + describe('guard conditions', () => { + test('should NOT register the route when enabled is false', async () => { + const authentication: AuthenticationConfig = { + ...upAuthConfig, + enabled: false, + }; + const app = await createResendOtpApp(authentication, action); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {email: 'test@example.com'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + + test('should NOT register the route when model is not found in models', async () => { + const app = await createResendOtpApp(upAuthConfig, action, {}); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {email: 'test@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + + test('should NOT register the route when the API is disabled via apis config', async () => { + const apiKey = `auth.users.all.resend-otp-${action}`; + const app = await createResendOtpApp( + upAuthConfig, + action, + authModels, + pgConfig, + {[apiKey]: {enabled: false}}, + ); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {email: 'test@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(pgQueryMock).not.toHaveBeenCalled(); + await app.close(); + }); + }); + + describe('happy path', () => { + test('should return 200 and ulid when user exists', async () => { + const app = await createResendOtpApp(upAuthConfig, action); + + pgQueryMock.mockResolvedValueOnce({ + rows: [ + {id: 1, email: 'alice@example.com', password: 'hashed_password'}, + ], + rowCount: 1, + }); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {email: 'alice@example.com'}, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.message).toBe('OTP resent to your email.'); + expect(body.data.requiresMfa).toBe(true); + expect(typeof body.data.ulid).toBe('string'); + expect(body.data.ulid.length).toBeGreaterThan(0); + + await app.close(); + }); + }); + + describe('unhappy path', () => { + test('should return 404 if user is not found', async () => { + const app = await createResendOtpApp(upAuthConfig, action); + + pgQueryMock.mockResolvedValueOnce({rows: [], rowCount: 0}); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {email: 'nonexistent@example.com'}, + }); + + expect(response.statusCode).toBe(404); + expect(response.json().message).toBe('User not found'); + await app.close(); + }); + + test('should return 400 if email is missing', async () => { + const app = await createResendOtpApp(upAuthConfig, action); + + const response = await app.inject({ + method: 'POST', + url: path, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + await app.close(); + }); + }); + }); +} diff --git a/tests/routes/search.test.ts b/tests/routes/search.test.ts index 6826e93..f50e998 100644 --- a/tests/routes/search.test.ts +++ b/tests/routes/search.test.ts @@ -590,6 +590,7 @@ describe('test search api', () => { describe('authentication', () => { const apisConfig = { 'model.users.name.search': { + enabled: true, authorization: true, }, }; diff --git a/tests/server.test.ts b/tests/server.test.ts index e5e9882..2d2bf62 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -13,6 +13,24 @@ import otpPlugin from '@/plugin/otp'; import rateLimitPlugin from '@/plugin/rate-limit'; import {startServer} from '@/server'; +import {registerEmailChangeRoute} from '@/routes/auth/change-email'; +import {registerChangePasswordRoute} from '@/routes/auth/change-password'; +import {registerDeleteMeRoute} from '@/routes/auth/delete-me'; +import {registerEditMeRoute} from '@/routes/auth/edit-me'; +import {registerForgotPasswordRoute} from '@/routes/auth/forgot-password'; +import {registerLoginRoute} from '@/routes/auth/login'; +import {registerMeRoute} from '@/routes/auth/me'; +import { + registerForgotPasswordOtpVerifyRoute, + registerLoginOtpVerifyRoute, + registerRegistrationOtpVerifyRoute, +} from '@/routes/auth/otp-verify'; +import {registerRegistrationRoute} from '@/routes/auth/registration'; +import { + registerForgotPasswordResendOtpRoute, + registerLoginResendOtpRoute, + registerRegistrationResendOtpRoute, +} from '@/routes/auth/resend-otp'; import {registerRoutes} from '@/routes/index'; import {Mode} from '@/interfaces'; @@ -61,6 +79,46 @@ vi.mock('@/routes/auth/registration', () => ({ registerRegistrationRoute: vi.fn(), })); +vi.mock('@/routes/auth/login', () => ({ + registerLoginRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/change-password', () => ({ + registerChangePasswordRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/forgot-password', () => ({ + registerForgotPasswordRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/otp-verify', () => ({ + registerForgotPasswordOtpVerifyRoute: vi.fn(), + registerLoginOtpVerifyRoute: vi.fn(), + registerRegistrationOtpVerifyRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/resend-otp', () => ({ + registerForgotPasswordResendOtpRoute: vi.fn(), + registerLoginResendOtpRoute: vi.fn(), + registerRegistrationResendOtpRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/me', () => ({ + registerMeRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/delete-me', () => ({ + registerDeleteMeRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/edit-me', () => ({ + registerEditMeRoute: vi.fn(), +})); + +vi.mock('@/routes/auth/change-email', () => ({ + registerEmailChangeRoute: vi.fn(), +})); + vi.mock('@/utils/welcome', () => ({ showWelcomeScreen: vi.fn(), })); @@ -530,6 +588,143 @@ describe('Server', () => { expect(registerMock).toHaveBeenCalledWith(otpPlugin); }); + + it('should register auth routes conditionally based on config', async () => { + const configWithAll: AppConfig = { + ...mockConfig, + integrations: { + email: { + provider: 'dummy', + }, + }, + authentication: { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + isVerifiedField: 'is_active', + }, + jwtSecret: 'test-secret', + mfaRequired: true, + }, + }, + }, + }; + + await startServer(configWithAll, 3000, 'dev'); + + expect(registerRegistrationRoute).toHaveBeenCalled(); + expect(registerLoginRoute).toHaveBeenCalled(); + expect(registerChangePasswordRoute).toHaveBeenCalled(); + expect(registerForgotPasswordRoute).toHaveBeenCalled(); + expect(registerLoginOtpVerifyRoute).toHaveBeenCalled(); + expect(registerRegistrationOtpVerifyRoute).toHaveBeenCalled(); + expect(registerForgotPasswordOtpVerifyRoute).toHaveBeenCalled(); + expect(registerLoginResendOtpRoute).toHaveBeenCalled(); + expect(registerRegistrationResendOtpRoute).toHaveBeenCalled(); + expect(registerForgotPasswordResendOtpRoute).toHaveBeenCalled(); + expect(registerMeRoute).toHaveBeenCalled(); + expect(registerDeleteMeRoute).toHaveBeenCalled(); + expect(registerEditMeRoute).toHaveBeenCalled(); + expect(registerEmailChangeRoute).toHaveBeenCalled(); + }); + + it('should not register registration OTP verify route when isVerifiedField is not set', async () => { + const configWithoutVerified: AppConfig = { + ...mockConfig, + integrations: { + email: { + provider: 'dummy', + }, + }, + authentication: { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + jwtSecret: 'test-secret', + mfaRequired: true, + }, + }, + }, + }; + + await startServer(configWithoutVerified, 3000, 'dev'); + + expect(registerRegistrationOtpVerifyRoute).not.toHaveBeenCalled(); + expect(registerRegistrationResendOtpRoute).not.toHaveBeenCalled(); + expect(registerEmailChangeRoute).not.toHaveBeenCalled(); + }); + + it('should not register login OTP verify route when mfa is not required', async () => { + const configWithoutMfa: AppConfig = { + ...mockConfig, + integrations: { + email: { + provider: 'dummy', + }, + }, + authentication: { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + jwtSecret: 'test-secret', + }, + }, + }, + }; + + await startServer(configWithoutMfa, 3000, 'dev'); + + expect(registerLoginOtpVerifyRoute).not.toHaveBeenCalled(); + expect(registerLoginResendOtpRoute).not.toHaveBeenCalled(); + }); + + it('should not register forgot-password routes when email is not configured', async () => { + const configWithoutEmail: AppConfig = { + ...mockConfig, + authentication: { + enabled: true, + provider: { + type: 'up-auth', + config: { + userModel: { + model: 'users', + idField: 'id', + usernameField: 'email', + passwordField: 'password', + }, + jwtSecret: 'test-secret', + mfaRequired: true, + }, + }, + }, + }; + + await startServer(configWithoutEmail, 3000, 'dev'); + + expect(registerForgotPasswordRoute).not.toHaveBeenCalled(); + expect(registerForgotPasswordOtpVerifyRoute).not.toHaveBeenCalled(); + expect(registerForgotPasswordResendOtpRoute).not.toHaveBeenCalled(); + }); }); describe('Integrations Configuration', () => {