From a699f54217f2ab673cf0697b8f2f4bf4c8279ab9 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 9 Apr 2026 18:07:05 -0500 Subject: [PATCH 1/4] feat(auth): add email verification code flow with backend services Implement server-side email verification using 6-digit codes with cooldown enforcement, expiry, and attempt limits. Includes DB migration, repository with advisory locks, Auth0 signup integration, notification template, and HTTP SDK methods. --- apps/api/drizzle/0029_fluffy_drax.sql | 18 + apps/api/drizzle/meta/0029_snapshot.json | 1290 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + .../controllers/auth/auth.controller.spec.ts | 109 ++ .../auth/controllers/auth/auth.controller.ts | 46 +- .../email-verification-code.schema.ts | 26 + apps/api/src/auth/model-schemas/index.ts | 1 + .../email-verification-code.repository.ts | 66 + apps/api/src/auth/routes/index.ts | 3 + .../send-verification-code.router.ts | 33 + .../src/auth/routes/signup/signup.router.ts | 44 + .../verify-email-code.router.ts | 50 + .../auth/services/auth0/auth0.service.spec.ts | 127 +- .../src/auth/services/auth0/auth0.service.ts | 15 + .../email-verification-code.service.spec.ts | 192 +++ .../email-verification-code.service.ts | 90 ++ ...ail-verification-code-notification.spec.ts | 16 + .../email-verification-code-notification.ts | 19 + apps/api/src/rest-app.ts | 5 +- .../services/user/user.service.integration.ts | 6 +- .../user/services/user/user.service.spec.ts | 86 ++ .../src/user/services/user/user.service.ts | 10 +- .../__snapshots__/docs.spec.ts.snap | 135 ++ .../email-sender/email-sender.service.ts | 2 +- .../http-sdk/src/auth/auth-http.service.ts | 11 +- packages/http-sdk/src/auth/auth-http.types.ts | 8 + 26 files changed, 2352 insertions(+), 63 deletions(-) create mode 100644 apps/api/drizzle/0029_fluffy_drax.sql create mode 100644 apps/api/drizzle/meta/0029_snapshot.json create mode 100644 apps/api/src/auth/controllers/auth/auth.controller.spec.ts create mode 100644 apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts create mode 100644 apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts create mode 100644 apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts create mode 100644 apps/api/src/auth/routes/signup/signup.router.ts create mode 100644 apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts create mode 100644 apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts create mode 100644 apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts create mode 100644 apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts create mode 100644 apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts create mode 100644 apps/api/src/user/services/user/user.service.spec.ts diff --git a/apps/api/drizzle/0029_fluffy_drax.sql b/apps/api/drizzle/0029_fluffy_drax.sql new file mode 100644 index 0000000000..a5dbe7b10a --- /dev/null +++ b/apps/api/drizzle/0029_fluffy_drax.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS "email_verification_codes" ( + "id" uuid PRIMARY KEY DEFAULT uuid_generate_v4() NOT NULL, + "user_id" uuid NOT NULL, + "email" varchar(255) NOT NULL, + "code" varchar(64) NOT NULL, + "expires_at" timestamp NOT NULL, + "attempts" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "email_verification_codes" ADD CONSTRAINT "email_verification_codes_user_id_userSetting_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."userSetting"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_verification_codes_user_id_idx" ON "email_verification_codes" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "email_verification_codes_expires_at_idx" ON "email_verification_codes" USING btree ("expires_at"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0029_snapshot.json b/apps/api/drizzle/meta/0029_snapshot.json new file mode 100644 index 0000000000..902882bba3 --- /dev/null +++ b/apps/api/drizzle/meta/0029_snapshot.json @@ -0,0 +1,1290 @@ +{ + "id": "23f3ad91-3b5d-4930-9a49-628ecd8f138a", + "prevId": "84347453-dc3f-4b93-bc6b-b1b3ff4fc21b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.user_wallets": { + "name": "user_wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "deployment_allowance": { + "name": "deployment_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "fee_allowance": { + "name": "fee_allowance", + "type": "numeric(20, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0.00'" + }, + "trial": { + "name": "trial", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_wallets_user_id_userSetting_id_fk": { + "name": "user_wallets_user_id_userSetting_id_fk", + "tableFrom": "user_wallets", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_wallets_user_id_unique": { + "name": "user_wallets_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "user_wallets_address_unique": { + "name": "user_wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + } + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_validated": { + "name": "is_validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_methods_fingerprint_payment_method_id_unique": { + "name": "payment_methods_fingerprint_payment_method_id_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_default_unique": { + "name": "payment_methods_user_id_is_default_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_default", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_methods\".\"is_default\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_fingerprint_idx": { + "name": "payment_methods_fingerprint_idx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_idx": { + "name": "payment_methods_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_is_validated_idx": { + "name": "payment_methods_user_id_is_validated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_validated", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_methods_user_id_fingerprint_payment_method_id_idx": { + "name": "payment_methods_user_id_fingerprint_payment_method_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_method_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_userSetting_id_fk": { + "name": "payment_methods_user_id_userSetting_id_fk", + "tableFrom": "payment_methods", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.stripe_transactions": { + "name": "stripe_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "stripe_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "stripe_transaction_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_refunded": { + "name": "amount_refunded", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_coupon_id": { + "name": "stripe_coupon_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_promotion_code_id": { + "name": "stripe_promotion_code_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payment_method_type": { + "name": "payment_method_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_brand": { + "name": "card_brand", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "card_last4": { + "name": "card_last4", + "type": "varchar(4)", + "primaryKey": false, + "notNull": false + }, + "receipt_url": { + "name": "receipt_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(1000)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_transactions_user_id_idx": { + "name": "stripe_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_payment_intent_id_idx": { + "name": "stripe_transactions_stripe_payment_intent_id_idx", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_charge_id_idx": { + "name": "stripe_transactions_stripe_charge_id_idx", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_coupon_id_idx": { + "name": "stripe_transactions_stripe_coupon_id_idx", + "columns": [ + { + "expression": "stripe_coupon_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_stripe_promotion_code_id_idx": { + "name": "stripe_transactions_stripe_promotion_code_id_idx", + "columns": [ + { + "expression": "stripe_promotion_code_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_status_idx": { + "name": "stripe_transactions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_created_at_idx": { + "name": "stripe_transactions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_transactions_user_id_created_at_idx": { + "name": "stripe_transactions_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_transactions_user_id_userSetting_id_fk": { + "name": "stripe_transactions_user_id_userSetting_id_fk", + "tableFrom": "stripe_transactions", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.wallet_settings": { + "name": "wallet_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "wallet_id": { + "name": "wallet_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "auto_reload_enabled": { + "name": "auto_reload_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "wallet_settings_user_id_idx": { + "name": "wallet_settings_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_settings_wallet_id_user_wallets_id_fk": { + "name": "wallet_settings_wallet_id_user_wallets_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "user_wallets", + "columnsFrom": [ + "wallet_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wallet_settings_user_id_userSetting_id_fk": { + "name": "wallet_settings_user_id_userSetting_id_fk", + "tableFrom": "wallet_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallet_settings_wallet_id_unique": { + "name": "wallet_settings_wallet_id_unique", + "nullsNotDistinct": false, + "columns": [ + "wallet_id" + ] + } + } + }, + "public.userSetting": { + "name": "userSetting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subscribedToNewsletter": { + "name": "subscribedToNewsletter", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "youtubeUsername": { + "name": "youtubeUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "twitterUsername": { + "name": "twitterUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "githubUsername": { + "name": "githubUsername", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_ip": { + "name": "last_ip", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "last_user_agent": { + "name": "last_user_agent", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "last_fingerprint": { + "name": "last_fingerprint", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "userSetting_userId_unique": { + "name": "userSetting_userId_unique", + "nullsNotDistinct": false, + "columns": [ + "userId" + ] + }, + "userSetting_username_unique": { + "name": "userSetting_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + } + }, + "public.template": { + "name": "template", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "copiedFromId": { + "name": "copiedFromId", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "isPublic": { + "name": "isPublic", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cpu": { + "name": "cpu", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "ram": { + "name": "ram", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "storage": { + "name": "storage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "sdl": { + "name": "sdl", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "template_userId_idx": { + "name": "template_userId_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.templateFavorite": { + "name": "templateFavorite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "templateId": { + "name": "templateId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "addedDate": { + "name": "addedDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "templateFavorite_userId_templateId_unique": { + "name": "templateFavorite_userId_templateId_unique", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "templateId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "templateFavorite_templateId_template_id_fk": { + "name": "templateFavorite_templateId_template_id_fk", + "tableFrom": "templateFavorite", + "tableTo": "template", + "columnsFrom": [ + "templateId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.deployment_settings": { + "name": "deployment_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dseq": { + "name": "dseq", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "id_auto_top_up_enabled_closed_idx": { + "name": "id_auto_top_up_enabled_closed_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "auto_top_up_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "closed", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_settings_user_id_userSetting_id_fk": { + "name": "deployment_settings_user_id_userSetting_id_fk", + "tableFrom": "deployment_settings", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "dseq_user_id_idx": { + "name": "dseq_user_id_idx", + "nullsNotDistinct": false, + "columns": [ + "dseq", + "user_id" + ] + } + } + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "hashed_key": { + "name": "hashed_key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key_format": { + "name": "key_format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_userSetting_id_fk": { + "name": "api_keys_user_id_userSetting_id_fk", + "tableFrom": "api_keys", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_hashed_key_unique": { + "name": "api_keys_hashed_key_unique", + "nullsNotDistinct": false, + "columns": [ + "hashed_key" + ] + } + } + }, + "public.email_verification_codes": { + "name": "email_verification_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuid_generate_v4()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_codes_user_id_idx": { + "name": "email_verification_codes_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_verification_codes_expires_at_idx": { + "name": "email_verification_codes_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_codes_user_id_userSetting_id_fk": { + "name": "email_verification_codes_user_id_userSetting_id_fk", + "tableFrom": "email_verification_codes", + "tableTo": "userSetting", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.stripe_transaction_status": { + "name": "stripe_transaction_status", + "schema": "public", + "values": [ + "created", + "pending", + "requires_action", + "succeeded", + "failed", + "refunded", + "canceled" + ] + }, + "public.stripe_transaction_type": { + "name": "stripe_transaction_type", + "schema": "public", + "values": [ + "payment_intent", + "coupon_claim" + ] + } + }, + "schemas": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 6febade28a..d1a454b701 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -204,6 +204,13 @@ "when": 1771591487804, "tag": "0028_right_red_ghost", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1771979737373, + "tag": "0029_fluffy_drax", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/auth/controllers/auth/auth.controller.spec.ts b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts new file mode 100644 index 0000000000..daf7cb73e9 --- /dev/null +++ b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts @@ -0,0 +1,109 @@ +import { ResponseError } from "auth0"; +import { container as rootContainer } from "tsyringe"; +import { mock } from "vitest-mock-extended"; + +import { AuthService } from "@src/auth/services/auth.service"; +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { AUTH0_DB_CONNECTION } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import type { UserService } from "@src/user/services/user/user.service"; +import { AuthController } from "./auth.controller"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(AuthController.name, () => { + describe("signup", () => { + it("creates user via auth0 service", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockResolvedValue(undefined); + + await controller.signup({ email: "user@example.com", password: "StrongPassword123!" }); + + expect(auth0Service.createUser).toHaveBeenCalledWith({ + email: "user@example.com", + password: "StrongPassword123!", + connection: AUTH0_DB_CONNECTION + }); + }); + + it("throws http error when auth0 returns a non-409 error", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue( + new ResponseError(400, JSON.stringify({ message: "PasswordStrengthError: Password is too weak" }), new Headers()) + ); + + await expect(controller.signup({ email: "user@example.com", password: "weak" })).rejects.toThrow("PasswordStrengthError: Password is too weak"); + }); + + it("converts 409 (user exists) to generic 422 error", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue(new ResponseError(409, JSON.stringify({ message: "The user already exists." }), new Headers())); + + await expect(controller.signup({ email: "user@example.com", password: "StrongPassword123!" })).rejects.toThrow( + "Unable to create account. Please try again or use a different email." + ); + }); + + it("re-throws non-ResponseError errors", async () => { + const { controller, auth0Service } = setup(); + + auth0Service.createUser.mockRejectedValue(new Error("Network failure")); + + await expect(controller.signup({ email: "user@example.com", password: "StrongPassword123!" })).rejects.toThrow("Network failure"); + }); + }); + + describe("sendVerificationCode", () => { + it("delegates to emailVerificationCodeService and wraps result in data", async () => { + const user = UserSeeder.create(); + const codeSentAt = new Date().toISOString(); + const { controller, emailVerificationCodeService } = setup({ user }); + + emailVerificationCodeService.sendCode.mockResolvedValue({ codeSentAt }); + + const result = await controller.sendVerificationCode(); + + expect(emailVerificationCodeService.sendCode).toHaveBeenCalledWith(user.id); + expect(result).toEqual({ data: { codeSentAt } }); + }); + }); + + describe("verifyEmailCode", () => { + it("delegates to emailVerificationCodeService with code", async () => { + const user = UserSeeder.create(); + const { controller, emailVerificationCodeService } = setup({ user }); + + emailVerificationCodeService.verifyCode.mockResolvedValue(undefined); + + await controller.verifyEmailCode({ data: { code: "123456" } }); + + expect(emailVerificationCodeService.verifyCode).toHaveBeenCalledWith(user.id, "123456"); + }); + }); + + function setup( + input: { + user?: ReturnType; + } = {} + ) { + const user = input.user ?? UserSeeder.create(); + + rootContainer.register(AuthService, { + useValue: mock({ + isAuthenticated: true, + currentUser: user + }) + }); + + const auth0Service = mock(); + const emailVerificationCodeService = mock(); + const userService = mock(); + + const controller = new AuthController(rootContainer.resolve(AuthService), auth0Service, userService, emailVerificationCodeService); + + return { controller, auth0Service, emailVerificationCodeService, userService }; + } +}); diff --git a/apps/api/src/auth/controllers/auth/auth.controller.ts b/apps/api/src/auth/controllers/auth/auth.controller.ts index 865d49a616..06ed717d62 100644 --- a/apps/api/src/auth/controllers/auth/auth.controller.ts +++ b/apps/api/src/auth/controllers/auth/auth.controller.ts @@ -1,9 +1,14 @@ +import { ResponseError } from "auth0"; +import assert from "http-assert"; import { singleton } from "tsyringe"; import type { SendVerificationEmailRequestInput } from "@src/auth"; import { VerifyEmailRequest } from "@src/auth/http-schemas/verify-email.schema"; +import type { SignupInput } from "@src/auth/routes/signup/signup.router"; +import type { VerifyEmailCodeRequest } from "@src/auth/routes/verify-email-code/verify-email-code.router"; import { AuthService, Protected } from "@src/auth/services/auth.service"; -import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { AUTH0_DB_CONNECTION, Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import { UserService } from "@src/user/services/user/user.service"; @singleton() @@ -11,9 +16,30 @@ export class AuthController { constructor( private readonly authService: AuthService, private readonly auth0: Auth0Service, - private readonly userService: UserService + private readonly userService: UserService, + private readonly emailVerificationCodeService: EmailVerificationCodeService ) {} + async signup(input: SignupInput) { + try { + await this.auth0.createUser({ + email: input.email, + password: input.password, + connection: AUTH0_DB_CONNECTION + }); + } catch (error) { + if (error instanceof ResponseError) { + if (error.statusCode === 409) { + assert(false, 422, "Unable to create account. Please try again or use a different email."); + } + const body = JSON.parse(error.body); + assert(false, error.statusCode, body.message); + } + + throw error; + } + } + @Protected() async sendVerificationEmail({ data: { userId } }: SendVerificationEmailRequestInput) { const { currentUser } = this.authService; @@ -24,6 +50,22 @@ export class AuthController { } } + @Protected() + async sendVerificationCode() { + const { currentUser } = this.authService; + + const result = await this.emailVerificationCodeService.sendCode(currentUser!.id); + + return { data: result }; + } + + @Protected() + async verifyEmailCode({ data: { code } }: VerifyEmailCodeRequest) { + const { currentUser } = this.authService; + + await this.emailVerificationCodeService.verifyCode(currentUser!.id, code); + } + async syncEmailVerified({ data: { email } }: VerifyEmailRequest) { const { emailVerified } = await this.userService.syncEmailVerified({ email }); diff --git a/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts b/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts new file mode 100644 index 0000000000..bfec6d842f --- /dev/null +++ b/apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { index, integer, pgTable, timestamp, uuid, varchar } from "drizzle-orm/pg-core"; + +import { Users } from "@src/user/model-schemas"; + +export const EmailVerificationCodes = pgTable( + "email_verification_codes", + { + id: uuid("id") + .primaryKey() + .notNull() + .default(sql`uuid_generate_v4()`), + userId: uuid("user_id") + .references(() => Users.id, { onDelete: "cascade" }) + .notNull(), + email: varchar("email", { length: 255 }).notNull(), + code: varchar("code", { length: 64 }).notNull(), + expiresAt: timestamp("expires_at").notNull(), + attempts: integer("attempts").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull() + }, + table => ({ + userIdIdx: index("email_verification_codes_user_id_idx").on(table.userId), + expiresAtIdx: index("email_verification_codes_expires_at_idx").on(table.expiresAt) + }) +); diff --git a/apps/api/src/auth/model-schemas/index.ts b/apps/api/src/auth/model-schemas/index.ts index 80523e83ff..83bf964cd3 100644 --- a/apps/api/src/auth/model-schemas/index.ts +++ b/apps/api/src/auth/model-schemas/index.ts @@ -1 +1,2 @@ export * from "./api-key/api-key.schema"; +export * from "./email-verification-code/email-verification-code.schema"; diff --git a/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts b/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts new file mode 100644 index 0000000000..471b30262b --- /dev/null +++ b/apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts @@ -0,0 +1,66 @@ +import { and, count, desc, eq, gt, sql } from "drizzle-orm"; +import { singleton } from "tsyringe"; + +import { type ApiPgDatabase, type ApiPgTables, InjectPg, InjectPgTable } from "@src/core/providers"; +import { type AbilityParams, BaseRepository } from "@src/core/repositories/base.repository"; +import { TxService } from "@src/core/services"; + +type Table = ApiPgTables["EmailVerificationCodes"]; +export type EmailVerificationCodeInput = Partial; +export type EmailVerificationCodeDbOutput = Table["$inferSelect"]; + +export type EmailVerificationCodeOutput = Omit & { + expiresAt: string; + createdAt: string; +}; + +@singleton() +export class EmailVerificationCodeRepository extends BaseRepository { + constructor( + @InjectPg() protected readonly pg: ApiPgDatabase, + @InjectPgTable("EmailVerificationCodes") protected readonly table: Table, + protected readonly txManager: TxService + ) { + super(pg, table, txManager, "EmailVerificationCode", "EmailVerificationCodes"); + } + + accessibleBy(...abilityParams: AbilityParams) { + return new EmailVerificationCodeRepository(this.pg, this.table, this.txManager).withAbility(...abilityParams) as this; + } + + async findActiveByUserIdForUpdate(userId: string): Promise { + const [result] = await this.cursor + .select() + .from(this.table) + .where(and(eq(this.table.userId, userId), gt(this.table.expiresAt, sql`now()`))) + .orderBy(desc(this.table.createdAt)) + .limit(1) + .for("update"); + + return result ? this.toOutput(result) : undefined; + } + + async countRecentByUserId(userId: string, since: Date): Promise { + const [result] = await this.cursor + .select({ count: count() }) + .from(this.table) + .where(and(eq(this.table.userId, userId), gt(this.table.createdAt, since))); + + return result?.count ?? 0; + } + + async incrementAttempts(id: string): Promise { + await this.cursor + .update(this.table) + .set({ attempts: sql`${this.table.attempts} + 1` }) + .where(eq(this.table.id, id)); + } + + protected toOutput(payload: EmailVerificationCodeDbOutput): EmailVerificationCodeOutput { + return { + ...payload, + expiresAt: payload.expiresAt.toISOString(), + createdAt: payload.createdAt.toISOString() + }; + } +} diff --git a/apps/api/src/auth/routes/index.ts b/apps/api/src/auth/routes/index.ts index e97060367a..2df734fa11 100644 --- a/apps/api/src/auth/routes/index.ts +++ b/apps/api/src/auth/routes/index.ts @@ -1,2 +1,5 @@ export * from "./send-verification-email/send-verification-email.router"; +export * from "./send-verification-code/send-verification-code.router"; +export * from "./signup/signup.router"; +export * from "./verify-email-code/verify-email-code.router"; export * from "./api-keys/api-keys.router"; diff --git a/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts b/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts new file mode 100644 index 0000000000..88be666a87 --- /dev/null +++ b/apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts @@ -0,0 +1,33 @@ +import { SendVerificationCodeResponseSchema } from "@akashnetwork/http-sdk"; +import { container } from "tsyringe"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_BEARER } from "@src/core/services/openapi-docs/openapi-security"; + +export const sendVerificationCodeRouter = new OpenApiHonoHandler(); + +const route = createRoute({ + method: "post", + path: "/v1/send-verification-code", + summary: "Sends a verification code to the authenticated user's email", + tags: ["Users"], + security: SECURITY_BEARER, + responses: { + 200: { + description: "Returns the timestamp when the code was sent", + content: { + "application/json": { + schema: SendVerificationCodeResponseSchema + } + } + }, + 429: { description: "Too many requests" } + } +}); + +sendVerificationCodeRouter.openapi(route, async function sendVerificationCode(c) { + const result = await container.resolve(AuthController).sendVerificationCode(); + return c.json(result, 200); +}); diff --git a/apps/api/src/auth/routes/signup/signup.router.ts b/apps/api/src/auth/routes/signup/signup.router.ts new file mode 100644 index 0000000000..971258c9a8 --- /dev/null +++ b/apps/api/src/auth/routes/signup/signup.router.ts @@ -0,0 +1,44 @@ +import { container } from "tsyringe"; +import { z } from "zod"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_NONE } from "@src/core/services/openapi-docs/openapi-security"; + +const signupInputSchema = z.object({ + email: z.string().email(), + password: z.string().min(8) +}); + +export type SignupInput = z.infer; + +const route = createRoute({ + method: "post", + path: "/v1/auth/signup", + summary: "Creates a new user without sending a verification email", + tags: ["Auth"], + security: SECURITY_NONE, + request: { + body: { + required: true, + content: { + "application/json": { + schema: signupInputSchema + } + } + } + }, + responses: { + 204: { + description: "User created successfully" + } + } +}); + +export const signupRouter = new OpenApiHonoHandler(); + +signupRouter.openapi(route, async function signup(c) { + await container.resolve(AuthController).signup(c.req.valid("json")); + return c.body(null, 204); +}); diff --git a/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts new file mode 100644 index 0000000000..72d22fb8dd --- /dev/null +++ b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts @@ -0,0 +1,50 @@ +import { container } from "tsyringe"; +import { z } from "zod"; + +import { AuthController } from "@src/auth/controllers/auth/auth.controller"; +import { createRoute } from "@src/core/lib/create-route/create-route"; +import { OpenApiHonoHandler } from "@src/core/services/open-api-hono-handler/open-api-hono-handler"; +import { SECURITY_BEARER } from "@src/core/services/openapi-docs/openapi-security"; + +export const verifyEmailCodeRouter = new OpenApiHonoHandler(); + +const VerifyEmailCodeRequestSchema = z.object({ + data: z.object({ + code: z + .string() + .length(6) + .regex(/^\d{6}$/, "Code must be exactly 6 digits") + }) +}); + +export type VerifyEmailCodeRequest = z.infer; + +const route = createRoute({ + method: "post", + path: "/v1/verify-email-code", + summary: "Verifies the email using a 6-digit code", + tags: ["Users"], + security: SECURITY_BEARER, + request: { + body: { + required: true, + content: { + "application/json": { + schema: VerifyEmailCodeRequestSchema + } + } + } + }, + responses: { + 204: { + description: "Email verified successfully" + }, + 400: { description: "Invalid or expired code" }, + 429: { description: "Too many attempts" } + } +}); + +verifyEmailCodeRouter.openapi(route, async function verifyEmailCode(c) { + await container.resolve(AuthController).verifyEmailCode(c.req.valid("json")); + return c.body(null, 204); +}); diff --git a/apps/api/src/auth/services/auth0/auth0.service.spec.ts b/apps/api/src/auth/services/auth0/auth0.service.spec.ts index 104d992364..8989beeeed 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.spec.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.spec.ts @@ -1,24 +1,63 @@ import { faker } from "@faker-js/faker"; import type { GetUsers200ResponseOneOfInner, ManagementClient } from "auth0"; -import { mock } from "vitest-mock-extended"; -import type { AuthConfigService } from "@src/auth/services/auth-config/auth-config.service"; import { Auth0Service } from "./auth0.service"; import { createAuth0User } from "@test/seeders"; describe(Auth0Service.name, () => { + describe("createUser", () => { + it("calls managementClient.users.create with verify_email false", async () => { + const create = jest.fn().mockResolvedValue({ data: {} }); + const { auth0Service } = setup({ users: { create } }); + + await auth0Service.createUser({ + email: "user@example.com", + password: "StrongPassword123!", + connection: "Username-Password-Authentication" + }); + + expect(create).toHaveBeenCalledWith({ + email: "user@example.com", + password: "StrongPassword123!", + connection: "Username-Password-Authentication", + verify_email: false + }); + }); + }); + + describe("sendVerificationEmail", () => { + it("calls managementClient.jobs.verifyEmail with user ID", async () => { + const verifyEmail = jest.fn().mockResolvedValue(undefined); + const { auth0Service } = setup({ jobs: { verifyEmail } }); + + await auth0Service.sendVerificationEmail("auth0|abc123"); + + expect(verifyEmail).toHaveBeenCalledWith({ user_id: "auth0|abc123" }); + }); + }); + + describe("markEmailVerified", () => { + it("calls managementClient.users.update with email_verified true", async () => { + const update = jest.fn().mockResolvedValue(undefined); + const { auth0Service } = setup({ users: { update } }); + + await auth0Service.markEmailVerified("auth0|xyz789"); + + expect(update).toHaveBeenCalledWith({ id: "auth0|xyz789" }, { email_verified: true }); + }); + }); + describe("getUserByEmail", () => { - it("should return user when user is found", async () => { + it("returns user when user is found", async () => { const email = faker.internet.email(); const mockUser: Partial = createAuth0User({ email: email, email_verified: true }); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [mockUser as GetUsers200ResponseOneOfInner] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [mockUser] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -26,12 +65,11 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should return null when no user is found", async () => { + it("returns null when no user is found", async () => { const email = faker.internet.email(); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -39,7 +77,7 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should return first user when multiple users are found", async () => { + it("returns first user when multiple users are found", async () => { const email = faker.internet.email(); const mockUsers: Partial[] = [ createAuth0User({ @@ -52,9 +90,8 @@ describe(Auth0Service.name, () => { }) ]; - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: mockUsers as GetUsers200ResponseOneOfInner[] - }); + const mockGetByEmail = jest.fn().mockResolvedValue({ data: mockUsers }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -62,52 +99,30 @@ describe(Auth0Service.name, () => { expect(mockGetByEmail).toHaveBeenCalledWith({ email }); }); - it("should handle empty email string", async () => { - const email = ""; + it("returns null for empty email string", async () => { + const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); - const { auth0Service, mockGetByEmail } = setup({ - mockUsers: [] - }); - - const result = await auth0Service.getUserByEmail(email); + const result = await auth0Service.getUserByEmail(""); expect(result).toBeNull(); expect(mockGetByEmail).toHaveBeenCalledWith({ email: "" }); }); - - function setup(input: { mockUsers?: GetUsers200ResponseOneOfInner[]; mockError?: Error }) { - const mockAuthConfig = mock(); - mockAuthConfig.get.mockImplementation((key: string) => { - const config = { - AUTH0_M2M_DOMAIN: "test-domain.auth0.com", - AUTH0_M2M_CLIENT_ID: "test-client-id", - AUTH0_M2M_SECRET: "test-client-secret" - }; - return config[key as keyof typeof config]; - }); - - const mockGetByEmail = jest.fn(); - const mockManagementClient = { - usersByEmail: { - getByEmail: mockGetByEmail - } - } as unknown as ManagementClient; - - if (input.mockError) { - mockGetByEmail.mockRejectedValue(input.mockError); - } else { - mockGetByEmail.mockResolvedValue({ - data: input.mockUsers || [] - }); - } - - const auth0Service = new Auth0Service(mockManagementClient); - - return { - auth0Service, - mockGetByEmail, - mockManagementClient - }; - } }); + + function setup( + input: { + jobs?: { verifyEmail: jest.Mock }; + users?: { update?: jest.Mock; create?: jest.Mock }; + usersByEmail?: { getByEmail: jest.Mock }; + } = {} + ) { + const mockManagementClient = { + ...input + } as unknown as ManagementClient; + + const auth0Service = new Auth0Service(mockManagementClient); + + return { auth0Service, mockManagementClient }; + } }); diff --git a/apps/api/src/auth/services/auth0/auth0.service.ts b/apps/api/src/auth/services/auth0/auth0.service.ts index 80e265c19c..8ad343ca6f 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.ts @@ -1,14 +1,29 @@ import { GetUsers200ResponseOneOfInner, ManagementClient } from "auth0"; import { singleton } from "tsyringe"; +export const AUTH0_DB_CONNECTION = "Username-Password-Authentication"; + @singleton() export class Auth0Service { constructor(private readonly managementClient: ManagementClient) {} + async createUser(input: { email: string; password: string; connection: string }): Promise { + await this.managementClient.users.create({ + email: input.email, + password: input.password, + connection: input.connection, + verify_email: false + }); + } + async sendVerificationEmail(userId: string) { await this.managementClient.jobs.verifyEmail({ user_id: userId }); } + async markEmailVerified(userId: string) { + await this.managementClient.users.update({ id: userId }, { email_verified: true }); + } + async getUserByEmail(email: string): Promise { const { data: users } = await this.managementClient.usersByEmail.getByEmail({ email }); if (users.length === 0) { diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts new file mode 100644 index 0000000000..471624757b --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts @@ -0,0 +1,192 @@ +import "@test/mocks/logger-service.mock"; + +import { faker } from "@faker-js/faker"; +import { createHash } from "crypto"; +import { mock } from "vitest-mock-extended"; + +import type { + EmailVerificationCodeOutput, + EmailVerificationCodeRepository +} from "@src/auth/repositories/email-verification-code/email-verification-code.repository"; +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; +import type { NotificationService } from "@src/notifications/services/notification/notification.service"; +import type { UserRepository } from "@src/user/repositories/user/user.repository"; +import { EmailVerificationCodeService } from "./email-verification-code.service"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(EmailVerificationCodeService.name, () => { + describe("sendCode", () => { + it("creates a new code and sends notification", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const createdRecord = createVerificationCodeOutput({ userId: user.id }); + const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.countRecentByUserId.mockResolvedValue(0); + emailVerificationCodeRepository.create.mockResolvedValue(createdRecord); + + const result = await service.sendCode(user.id); + + expect(emailVerificationCodeRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user.id, + email: user.email, + code: expect.stringMatching(/^[a-f0-9]{64}$/) + }) + ); + expect(notificationService.createNotification).toHaveBeenCalled(); + expect(result.codeSentAt).toBe(createdRecord.createdAt); + }); + + it("throws 429 when rate limit exceeded", async () => { + const user = UserSeeder.create({ email: "test@example.com" }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.countRecentByUserId.mockResolvedValue(5); + + await expect(service.sendCode(user.id)).rejects.toThrow(); + }); + + it("throws 404 when user not found", async () => { + const { service, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(undefined); + + await expect(service.sendCode("nonexistent")).rejects.toThrow(); + }); + + it("throws 400 when user has no email", async () => { + const user = UserSeeder.create({ email: null }); + const { service, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + + await expect(service.sendCode(user.id)).rejects.toThrow(); + }); + }); + + describe("verifyCode", () => { + it("verifies valid code and marks email as verified", async () => { + const code = "123456"; + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await service.verifyCode(user.id, code); + + expect(auth0Service.markEmailVerified).toHaveBeenCalledWith(user.userId); + expect(userRepository.updateById).toHaveBeenCalledWith(user.id, { emailVerified: true }); + }); + + it("throws and increments attempts for invalid code", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "999999")).rejects.toThrow("Invalid verification code"); + expect(emailVerificationCodeRepository.incrementAttempts).toHaveBeenCalledWith(record.id); + }); + + it("rejects when max attempts exceeded", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 5 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + expect(emailVerificationCodeRepository.incrementAttempts).not.toHaveBeenCalled(); + }); + + it("throws and increments attempts for mismatched length code", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + + await expect(service.verifyCode(user.id, "12345")).rejects.toThrow("Invalid verification code"); + expect(emailVerificationCodeRepository.incrementAttempts).toHaveBeenCalledWith(record.id); + }); + + it("updates local DB even if Auth0 call fails", async () => { + const code = "123456"; + const user = UserSeeder.create({ userId: "auth0|123" }); + const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); + const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(record); + auth0Service.markEmailVerified.mockRejectedValue(new Error("Auth0 unavailable")); + + await expect(service.verifyCode(user.id, code)).rejects.toThrow("Auth0 unavailable"); + + expect(userRepository.updateById).toHaveBeenCalledWith(user.id, { emailVerified: true }); + }); + + it("rejects when no active code exists", async () => { + const user = UserSeeder.create({ userId: "auth0|123" }); + const { service, emailVerificationCodeRepository, userRepository } = setup(); + + userRepository.findById.mockResolvedValue(user); + emailVerificationCodeRepository.findActiveByUserIdForUpdate.mockResolvedValue(undefined); + + await expect(service.verifyCode(user.id, "123456")).rejects.toThrow(); + }); + }); + + function hashCode(code: string) { + return createHash("sha256").update(code).digest("hex"); + } + + function createVerificationCodeOutput(overrides: Partial = {}): EmailVerificationCodeOutput { + return { + id: faker.string.uuid(), + userId: faker.string.uuid(), + email: faker.internet.email(), + code: hashCode(faker.string.numeric(6)), + expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(), + attempts: 0, + createdAt: new Date().toISOString(), + ...overrides + }; + } + + function setup( + input: { + emailVerificationCodeRepository?: EmailVerificationCodeRepository; + auth0Service?: Auth0Service; + notificationService?: NotificationService; + userRepository?: UserRepository; + logger?: LoggerService; + } = {} + ) { + const emailVerificationCodeRepository = input.emailVerificationCodeRepository ?? mock(); + const auth0Service = input.auth0Service ?? mock(); + const notificationService = input.notificationService ?? mock(); + const userRepository = input.userRepository ?? mock(); + const logger = input.logger ?? mock(); + + const service = new EmailVerificationCodeService(emailVerificationCodeRepository, auth0Service, notificationService, userRepository, logger); + + return { + service, + emailVerificationCodeRepository, + auth0Service, + notificationService, + userRepository, + logger + }; + } +}); diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts new file mode 100644 index 0000000000..facd0ac477 --- /dev/null +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts @@ -0,0 +1,90 @@ +import { createHash, randomInt, timingSafeEqual } from "crypto"; +import assert from "http-assert"; +import { singleton } from "tsyringe"; + +import { EmailVerificationCodeRepository } from "@src/auth/repositories/email-verification-code/email-verification-code.repository"; +import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { WithTransaction } from "@src/core"; +import { LoggerService } from "@src/core/providers/logging.provider"; +import { NotificationService } from "@src/notifications/services/notification/notification.service"; +import { emailVerificationCodeNotification } from "@src/notifications/services/notification-templates/email-verification-code-notification"; +import { UserRepository } from "@src/user/repositories/user/user.repository"; + +const CODE_EXPIRY_MS = 10 * 60 * 1000; +const MAX_ATTEMPTS = 5; +const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; +const MAX_CODES_PER_WINDOW = 5; + +function hashCode(code: string): string { + return createHash("sha256").update(code).digest("hex"); +} + +@singleton() +export class EmailVerificationCodeService { + constructor( + private readonly emailVerificationCodeRepository: EmailVerificationCodeRepository, + private readonly auth0Service: Auth0Service, + private readonly notificationService: NotificationService, + private readonly userRepository: UserRepository, + private readonly logger: LoggerService + ) {} + + async sendCode(userInternalId: string): Promise<{ codeSentAt: string }> { + const user = await this.userRepository.findById(userInternalId); + assert(user, 404, "User not found"); + assert(user.email, 400, "User has no email address"); + + const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); + const recentCount = await this.emailVerificationCodeRepository.countRecentByUserId(userInternalId, since); + assert(recentCount < MAX_CODES_PER_WINDOW, 429, "Too many verification code requests. Please try again later."); + + const code = randomInt(100000, 1000000).toString(); + const expiresAt = new Date(Date.now() + CODE_EXPIRY_MS); + + const record = await this.emailVerificationCodeRepository.create({ + userId: userInternalId, + email: user.email, + code: hashCode(code), + expiresAt + }); + + await this.notificationService.createNotification(emailVerificationCodeNotification({ id: userInternalId, email: user.email }, { code })); + + this.logger.info({ event: "VERIFICATION_CODE_SENT", userId: userInternalId }); + + return { codeSentAt: record.createdAt }; + } + + async verifyCode(userInternalId: string, code: string): Promise { + const auth0UserId = await this.verifyCodeInTransaction(userInternalId, code); + + await this.auth0Service.markEmailVerified(auth0UserId); + + this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId }); + } + + @WithTransaction() + private async verifyCodeInTransaction(userInternalId: string, code: string): Promise { + const [user, record] = await Promise.all([ + this.userRepository.findById(userInternalId), + this.emailVerificationCodeRepository.findActiveByUserIdForUpdate(userInternalId) + ]); + assert(user, 404, "User not found"); + assert(user.userId, 400, "User has no Auth0 ID"); + assert(record, 400, "No active verification code. Please request a new one."); + assert(record.attempts < MAX_ATTEMPTS, 429, "Too many attempts. Please request a new code."); + + const codeBuffer = Buffer.from(hashCode(code)); + const recordBuffer = Buffer.from(record.code); + const isCodeValid = codeBuffer.length === recordBuffer.length && timingSafeEqual(recordBuffer, codeBuffer); + + if (!isCodeValid) { + await this.emailVerificationCodeRepository.incrementAttempts(record.id); + assert(false, 400, "Invalid verification code"); + } + + await this.userRepository.updateById(userInternalId, { emailVerified: true }); + + return user.userId; + } +} diff --git a/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts new file mode 100644 index 0000000000..5dcfa31b01 --- /dev/null +++ b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts @@ -0,0 +1,16 @@ +import { emailVerificationCodeNotification } from "./email-verification-code-notification"; + +describe(emailVerificationCodeNotification.name, () => { + it("returns notification with correct structure", () => { + const user = { id: "user-123", email: "test@example.com" }; + const vars = { code: "123456" }; + + const result = emailVerificationCodeNotification(user, vars); + + expect(result.notificationId).toMatch(/^emailVerificationCode\.user-123\.[0-9a-f-]{36}$/); + expect(result.payload.summary).toBe("Your verification code"); + expect(result.payload.description).toContain("123456"); + expect(result.payload.description).toContain("expires in 10 minutes"); + expect(result.user).toEqual({ id: "user-123", email: "test@example.com" }); + }); +}); diff --git a/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts new file mode 100644 index 0000000000..33fc60ed67 --- /dev/null +++ b/apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts @@ -0,0 +1,19 @@ +import { randomUUID } from "crypto"; + +import type { CreateNotificationInput } from "../notification/notification.service"; + +export function emailVerificationCodeNotification(user: { id: string; email: string }, vars: { code: string }): CreateNotificationInput { + return { + notificationId: `emailVerificationCode.${user.id}.${randomUUID()}`, + payload: { + summary: "Your verification code", + description: + `Your email verification code is: ${vars.code}. ` + + `This code expires in 10 minutes. If you did not request this code, please ignore this email.` + }, + user: { + id: user.id, + email: user.email + } + }; +} diff --git a/apps/api/src/rest-app.ts b/apps/api/src/rest-app.ts index 7bf74d66d3..03ff78ace8 100644 --- a/apps/api/src/rest-app.ts +++ b/apps/api/src/rest-app.ts @@ -32,7 +32,7 @@ import { legacyRouter } from "./routers/legacyRouter"; import { web3IndexRouter } from "./routers/web3indexRouter"; import { bytesToHumanReadableSize } from "./utils/files"; import { addressRouter } from "./address"; -import { apiKeysRouter, sendVerificationEmailRouter } from "./auth"; +import { apiKeysRouter, sendVerificationCodeRouter, sendVerificationEmailRouter, signupRouter, verifyEmailCodeRouter } from "./auth"; import { getBalancesRouter, getWalletListRouter, @@ -123,6 +123,9 @@ const openApiHonoHandlers: OpenApiHonoHandler[] = [ userSettingsRouter, userTemplatesRouter, sendVerificationEmailRouter, + sendVerificationCodeRouter, + signupRouter, + verifyEmailCodeRouter, verifyEmailRouter, deploymentSettingRouter, deploymentsRouter, diff --git a/apps/api/src/user/services/user/user.service.integration.ts b/apps/api/src/user/services/user/user.service.integration.ts index 2ad460196c..9f5fde3e65 100644 --- a/apps/api/src/user/services/user/user.service.integration.ts +++ b/apps/api/src/user/services/user/user.service.integration.ts @@ -4,6 +4,7 @@ import { container } from "tsyringe"; import { mock } from "vitest-mock-extended"; import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import type { AnalyticsService } from "@src/core/services/analytics/analytics.service"; import type { NotificationService } from "@src/notifications/services/notification/notification.service"; @@ -343,7 +344,10 @@ describe(UserService.name, () => { mock({ createDefaultChannel: input?.createDefaultNotificationChannel ?? (() => Promise.resolve()) }), - auth0Service + auth0Service, + mock({ + sendCode: jest.fn().mockResolvedValue({ codeSentAt: new Date().toISOString() }) + }) ); return { service, analyticsService, logger, auth0Service, userRepository }; diff --git a/apps/api/src/user/services/user/user.service.spec.ts b/apps/api/src/user/services/user/user.service.spec.ts new file mode 100644 index 0000000000..d6644a8d21 --- /dev/null +++ b/apps/api/src/user/services/user/user.service.spec.ts @@ -0,0 +1,86 @@ +import "@test/mocks/logger-service.mock"; + +import { faker } from "@faker-js/faker"; +import { mock } from "vitest-mock-extended"; + +import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import type { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; +import type { LoggerService } from "@src/core/providers/logging.provider"; +import type { AnalyticsService } from "@src/core/services/analytics/analytics.service"; +import type { NotificationService } from "@src/notifications/services/notification/notification.service"; +import type { UserRepository } from "@src/user/repositories/user/user.repository"; +import type { RegisterUserInput } from "./user.service"; +import { UserService } from "./user.service"; + +import { UserSeeder } from "@test/seeders/user.seeder"; + +describe(UserService.name, () => { + describe("registerUser", () => { + it("sends verification code when email is not verified", async () => { + const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + emailVerificationCodeService.sendCode.mockResolvedValue({ codeSentAt: new Date().toISOString() }); + + await service.registerUser(createRegisterInput({ emailVerified: false })); + + expect(emailVerificationCodeService.sendCode).toHaveBeenCalledWith(user.id); + }); + + it("does not send verification code when email is already verified", async () => { + const user = UserSeeder.create({ emailVerified: true, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + + await service.registerUser(createRegisterInput({ emailVerified: true })); + + expect(emailVerificationCodeService.sendCode).not.toHaveBeenCalled(); + }); + + it("logs error but does not throw when verification code send fails", async () => { + const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const { service, emailVerificationCodeService, userRepository, notificationService, logger } = setup(); + const sendError = new Error("Send failed"); + + userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); + notificationService.createDefaultChannel.mockResolvedValue(undefined); + emailVerificationCodeService.sendCode.mockRejectedValue(sendError); + + const result = await service.registerUser(createRegisterInput({ emailVerified: false })); + + expect(result.id).toBe(user.id); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: "FAILED_TO_SEND_INITIAL_VERIFICATION_CODE", id: user.id, error: sendError })); + }); + }); + + function setup() { + const userRepository = mock(); + const analyticsService = mock(); + const logger = mock(); + const notificationService = mock(); + const auth0Service = mock(); + const emailVerificationCodeService = mock(); + + const service = new UserService(userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService); + + return { service, userRepository, analyticsService, logger, notificationService, auth0Service, emailVerificationCodeService }; + } + + function createRegisterInput(overrides: Partial = {}): RegisterUserInput { + return { + userId: faker.string.uuid(), + wantedUsername: faker.internet.userName(), + email: faker.internet.email(), + emailVerified: false, + subscribedToNewsletter: false, + ip: faker.internet.ip(), + userAgent: faker.internet.userAgent(), + fingerprint: faker.string.uuid(), + ...overrides + }; + } +}); diff --git a/apps/api/src/user/services/user/user.service.ts b/apps/api/src/user/services/user/user.service.ts index 515b74a932..8204284a2e 100644 --- a/apps/api/src/user/services/user/user.service.ts +++ b/apps/api/src/user/services/user/user.service.ts @@ -3,6 +3,7 @@ import randomInt from "lodash/random"; import { singleton } from "tsyringe"; import { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import { LoggerService } from "@src/core/providers/logging.provider"; import { isUniqueViolation } from "@src/core/repositories/base.repository"; import { AnalyticsService } from "@src/core/services/analytics/analytics.service"; @@ -16,7 +17,8 @@ export class UserService { private readonly analyticsService: AnalyticsService, private readonly logger: LoggerService, private readonly notificationService: NotificationService, - private readonly auth0: Auth0Service + private readonly auth0: Auth0Service, + private readonly emailVerificationCodeService: EmailVerificationCodeService ) {} async registerUser(data: RegisterUserInput): Promise<{ @@ -59,6 +61,12 @@ export class UserService { this.logger.error({ event: "FAILED_TO_CREATE_DEFAULT_NOTIFICATION_CHANNEL", id: user.id, error: result.error }); } + if (!data.emailVerified && user.email) { + await this.emailVerificationCodeService.sendCode(user.id).catch(error => { + this.logger.error({ event: "FAILED_TO_SEND_INITIAL_VERIFICATION_CODE", id: user.id, error }); + }); + } + const { id, userId, username, email, emailVerified, stripeCustomerId, bio, subscribedToNewsletter, youtubeUsername, twitterUsername, githubUsername } = user; diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 1965bb20d3..5104610e3f 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -2641,6 +2641,44 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/auth/signup": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "email": { + "format": "email", + "type": "string", + }, + "password": { + "minLength": 8, + "type": "string", + }, + }, + "required": [ + "email", + "password", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "204": { + "description": "User created successfully", + }, + }, + "security": [], + "summary": "Creates a new user without sending a verification email", + "tags": [ + "Auth", + ], + }, + }, "/v1/balances": { "get": { "parameters": [ @@ -12524,6 +12562,50 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/send-verification-code": { + "post": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "codeSentAt": { + "type": "string", + }, + }, + "required": [ + "codeSentAt", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "description": "Returns the timestamp when the code was sent", + }, + "429": { + "description": "Too many requests", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + ], + "summary": "Sends a verification code to the authenticated user's email", + "tags": [ + "Users", + ], + }, + }, "/v1/send-verification-email": { "post": { "requestBody": { @@ -15559,6 +15641,59 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` ], }, }, + "/v1/verify-email-code": { + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "properties": { + "data": { + "properties": { + "code": { + "maxLength": 6, + "minLength": 6, + "pattern": "^\\d{6}$", + "type": "string", + }, + }, + "required": [ + "code", + ], + "type": "object", + }, + }, + "required": [ + "data", + ], + "type": "object", + }, + }, + }, + "required": true, + }, + "responses": { + "204": { + "description": "Email verified successfully", + }, + "400": { + "description": "Invalid or expired code", + }, + "429": { + "description": "Too many attempts", + }, + }, + "security": [ + { + "BearerAuth": [], + }, + ], + "summary": "Verifies the email using a 6-digit code", + "tags": [ + "Users", + ], + }, + }, "/v1/wallet-settings": { "delete": { "description": "Deletes wallet settings for a user wallet", diff --git a/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts b/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts index 2bbeafa19e..1acd0a0e55 100644 --- a/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts +++ b/apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts @@ -36,7 +36,7 @@ export class EmailSenderService { payload: { subject, content: sanitizeHtml(content, { - allowedTags: ["a"], + allowedTags: ["a", "strong"], allowedAttributes: { a: ["href"] } diff --git a/packages/http-sdk/src/auth/auth-http.service.ts b/packages/http-sdk/src/auth/auth-http.service.ts index e3756b6d29..8aca6d7614 100644 --- a/packages/http-sdk/src/auth/auth-http.service.ts +++ b/packages/http-sdk/src/auth/auth-http.service.ts @@ -1,17 +1,26 @@ import type { AxiosRequestConfig } from "axios"; import { HttpService } from "../http/http.service"; -import type { VerifyEmailResponse } from "./auth-http.types"; +import type { SendVerificationCodeResponse, VerifyEmailResponse } from "./auth-http.types"; export class AuthHttpService extends HttpService { constructor(config?: Pick) { super(config); } + /** @deprecated Use {@link sendVerificationCode} instead. This targets the legacy link-based verification endpoint. */ async sendVerificationEmail(userId: string) { return this.post("/v1/send-verification-email", { data: { userId } }); } + async sendVerificationCode() { + return this.extractData(await this.post("/v1/send-verification-code")); + } + + async verifyEmailCode(code: string) { + await this.post("/v1/verify-email-code", { data: { code } }); + } + async verifyEmail(email: string) { return this.extractData(await this.post("/v1/verify-email", { data: { email } }, { withCredentials: true })); } diff --git a/packages/http-sdk/src/auth/auth-http.types.ts b/packages/http-sdk/src/auth/auth-http.types.ts index 69de25797c..28101a2e45 100644 --- a/packages/http-sdk/src/auth/auth-http.types.ts +++ b/packages/http-sdk/src/auth/auth-http.types.ts @@ -7,3 +7,11 @@ export const VerifyEmailResponseSchema = z.object({ }); export type VerifyEmailResponse = z.infer; + +export const SendVerificationCodeResponseSchema = z.object({ + data: z.object({ + codeSentAt: z.string() + }) +}); + +export type SendVerificationCodeResponse = z.infer; From f4a31edb9fb0888c2a4038cbdb244046c71e1401 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:11:36 -0500 Subject: [PATCH 2/4] fix(auth): address review comments and fix test failures Replace http-assert with http-errors for error handling consistency. Add safe JSON parsing for Auth0 error bodies. Fix UserSeeder import to use existing createUser function and replace jest.fn with vi.fn. --- .../controllers/auth/auth.controller.spec.ts | 10 +++++----- .../auth/controllers/auth/auth.controller.ts | 17 ++++++++++++---- .../email-verification-code.service.spec.ts | 20 +++++++++---------- .../services/user/user.service.integration.ts | 5 +++-- .../user/services/user/user.service.spec.ts | 8 ++++---- 5 files changed, 35 insertions(+), 25 deletions(-) diff --git a/apps/api/src/auth/controllers/auth/auth.controller.spec.ts b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts index daf7cb73e9..079171caef 100644 --- a/apps/api/src/auth/controllers/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/controllers/auth/auth.controller.spec.ts @@ -9,7 +9,7 @@ import type { EmailVerificationCodeService } from "@src/auth/services/email-veri import type { UserService } from "@src/user/services/user/user.service"; import { AuthController } from "./auth.controller"; -import { UserSeeder } from "@test/seeders/user.seeder"; +import { createUser } from "@test/seeders/user.seeder"; describe(AuthController.name, () => { describe("signup", () => { @@ -58,7 +58,7 @@ describe(AuthController.name, () => { describe("sendVerificationCode", () => { it("delegates to emailVerificationCodeService and wraps result in data", async () => { - const user = UserSeeder.create(); + const user = createUser(); const codeSentAt = new Date().toISOString(); const { controller, emailVerificationCodeService } = setup({ user }); @@ -73,7 +73,7 @@ describe(AuthController.name, () => { describe("verifyEmailCode", () => { it("delegates to emailVerificationCodeService with code", async () => { - const user = UserSeeder.create(); + const user = createUser(); const { controller, emailVerificationCodeService } = setup({ user }); emailVerificationCodeService.verifyCode.mockResolvedValue(undefined); @@ -86,10 +86,10 @@ describe(AuthController.name, () => { function setup( input: { - user?: ReturnType; + user?: ReturnType; } = {} ) { - const user = input.user ?? UserSeeder.create(); + const user = input.user ?? createUser(); rootContainer.register(AuthService, { useValue: mock({ diff --git a/apps/api/src/auth/controllers/auth/auth.controller.ts b/apps/api/src/auth/controllers/auth/auth.controller.ts index 06ed717d62..1e4e01fea1 100644 --- a/apps/api/src/auth/controllers/auth/auth.controller.ts +++ b/apps/api/src/auth/controllers/auth/auth.controller.ts @@ -1,5 +1,5 @@ import { ResponseError } from "auth0"; -import assert from "http-assert"; +import createError from "http-errors"; import { singleton } from "tsyringe"; import type { SendVerificationEmailRequestInput } from "@src/auth"; @@ -30,16 +30,25 @@ export class AuthController { } catch (error) { if (error instanceof ResponseError) { if (error.statusCode === 409) { - assert(false, 422, "Unable to create account. Please try again or use a different email."); + throw createError(422, "Unable to create account. Please try again or use a different email."); } - const body = JSON.parse(error.body); - assert(false, error.statusCode, body.message); + + throw createError(error.statusCode, this.extractAuth0Message(error)); } throw error; } } + private extractAuth0Message(error: ResponseError): string { + try { + const body = JSON.parse(error.body); + return body.message || error.message; + } catch { + return error.message; + } + } + @Protected() async sendVerificationEmail({ data: { userId } }: SendVerificationEmailRequestInput) { const { currentUser } = this.authService; diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts index 471624757b..32c6b711f3 100644 --- a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts @@ -14,12 +14,12 @@ import type { NotificationService } from "@src/notifications/services/notificati import type { UserRepository } from "@src/user/repositories/user/user.repository"; import { EmailVerificationCodeService } from "./email-verification-code.service"; -import { UserSeeder } from "@test/seeders/user.seeder"; +import { createUser } from "@test/seeders/user.seeder"; describe(EmailVerificationCodeService.name, () => { describe("sendCode", () => { it("creates a new code and sends notification", async () => { - const user = UserSeeder.create({ email: "test@example.com" }); + const user = createUser({ email: "test@example.com" }); const createdRecord = createVerificationCodeOutput({ userId: user.id }); const { service, emailVerificationCodeRepository, userRepository, notificationService } = setup(); @@ -41,7 +41,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("throws 429 when rate limit exceeded", async () => { - const user = UserSeeder.create({ email: "test@example.com" }); + const user = createUser({ email: "test@example.com" }); const { service, emailVerificationCodeRepository, userRepository } = setup(); userRepository.findById.mockResolvedValue(user); @@ -59,7 +59,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("throws 400 when user has no email", async () => { - const user = UserSeeder.create({ email: null }); + const user = createUser({ email: null }); const { service, userRepository } = setup(); userRepository.findById.mockResolvedValue(user); @@ -71,7 +71,7 @@ describe(EmailVerificationCodeService.name, () => { describe("verifyCode", () => { it("verifies valid code and marks email as verified", async () => { const code = "123456"; - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); @@ -85,7 +85,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("throws and increments attempts for invalid code", async () => { - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); const { service, emailVerificationCodeRepository, userRepository } = setup(); @@ -97,7 +97,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("rejects when max attempts exceeded", async () => { - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 5 }); const { service, emailVerificationCodeRepository, userRepository } = setup(); @@ -109,7 +109,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("throws and increments attempts for mismatched length code", async () => { - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const record = createVerificationCodeOutput({ userId: user.id, code: hashCode("123456"), attempts: 0 }); const { service, emailVerificationCodeRepository, userRepository } = setup(); @@ -122,7 +122,7 @@ describe(EmailVerificationCodeService.name, () => { it("updates local DB even if Auth0 call fails", async () => { const code = "123456"; - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const record = createVerificationCodeOutput({ userId: user.id, code: hashCode(code), attempts: 0 }); const { service, emailVerificationCodeRepository, userRepository, auth0Service } = setup(); @@ -136,7 +136,7 @@ describe(EmailVerificationCodeService.name, () => { }); it("rejects when no active code exists", async () => { - const user = UserSeeder.create({ userId: "auth0|123" }); + const user = createUser({ userId: "auth0|123" }); const { service, emailVerificationCodeRepository, userRepository } = setup(); userRepository.findById.mockResolvedValue(user); diff --git a/apps/api/src/user/services/user/user.service.integration.ts b/apps/api/src/user/services/user/user.service.integration.ts index 9f5fde3e65..43b3206b4e 100644 --- a/apps/api/src/user/services/user/user.service.integration.ts +++ b/apps/api/src/user/services/user/user.service.integration.ts @@ -1,6 +1,7 @@ import { faker } from "@faker-js/faker"; import type { GetUsers200ResponseOneOfInner } from "auth0"; import { container } from "tsyringe"; +import { vi } from "vitest"; import { mock } from "vitest-mock-extended"; import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; @@ -15,7 +16,7 @@ import { UserService } from "./user.service"; describe(UserService.name, () => { describe("registerUser", () => { it("registers a new user", async () => { - const createDefaultNotificationChannel = jest.fn(() => Promise.resolve()); + const createDefaultNotificationChannel = vi.fn(() => Promise.resolve()); const { service, analyticsService, logger } = setup({ createDefaultNotificationChannel }); const input: RegisterUserInput = { @@ -346,7 +347,7 @@ describe(UserService.name, () => { }), auth0Service, mock({ - sendCode: jest.fn().mockResolvedValue({ codeSentAt: new Date().toISOString() }) + sendCode: vi.fn().mockResolvedValue({ codeSentAt: new Date().toISOString() }) }) ); diff --git a/apps/api/src/user/services/user/user.service.spec.ts b/apps/api/src/user/services/user/user.service.spec.ts index d6644a8d21..0741626dd9 100644 --- a/apps/api/src/user/services/user/user.service.spec.ts +++ b/apps/api/src/user/services/user/user.service.spec.ts @@ -12,12 +12,12 @@ import type { UserRepository } from "@src/user/repositories/user/user.repository import type { RegisterUserInput } from "./user.service"; import { UserService } from "./user.service"; -import { UserSeeder } from "@test/seeders/user.seeder"; +import { createUser } from "@test/seeders/user.seeder"; describe(UserService.name, () => { describe("registerUser", () => { it("sends verification code when email is not verified", async () => { - const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const user = createUser({ emailVerified: false, email: "test@example.com" }); const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); @@ -30,7 +30,7 @@ describe(UserService.name, () => { }); it("does not send verification code when email is already verified", async () => { - const user = UserSeeder.create({ emailVerified: true, email: "test@example.com" }); + const user = createUser({ emailVerified: true, email: "test@example.com" }); const { service, emailVerificationCodeService, userRepository, notificationService } = setup(); userRepository.upsertOnExternalIdConflict.mockResolvedValue(user); @@ -42,7 +42,7 @@ describe(UserService.name, () => { }); it("logs error but does not throw when verification code send fails", async () => { - const user = UserSeeder.create({ emailVerified: false, email: "test@example.com" }); + const user = createUser({ emailVerified: false, email: "test@example.com" }); const { service, emailVerificationCodeService, userRepository, notificationService, logger } = setup(); const sendError = new Error("Send failed"); From 3cd8f4aa6c57dc08e240fc69955397cc3f7708b5 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:24:08 -0500 Subject: [PATCH 3/4] refactor(auth): simplify verification code implementation - Replace jest.fn/jest.Mock with vi.fn/Mock from vitest in auth0.service.spec - Remove redundant .length(6) validation (regex already enforces 6 digits) - Parallelize independent DB reads in sendCode via Promise.all --- .../verify-email-code.router.ts | 5 +---- .../auth/services/auth0/auth0.service.spec.ts | 22 ++++++++++--------- .../email-verification-code.service.ts | 9 ++++---- .../__snapshots__/docs.spec.ts.snap | 2 -- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts index 72d22fb8dd..8483848a7c 100644 --- a/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts +++ b/apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts @@ -10,10 +10,7 @@ export const verifyEmailCodeRouter = new OpenApiHonoHandler(); const VerifyEmailCodeRequestSchema = z.object({ data: z.object({ - code: z - .string() - .length(6) - .regex(/^\d{6}$/, "Code must be exactly 6 digits") + code: z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits") }) }); diff --git a/apps/api/src/auth/services/auth0/auth0.service.spec.ts b/apps/api/src/auth/services/auth0/auth0.service.spec.ts index 8989beeeed..4f971f25b3 100644 --- a/apps/api/src/auth/services/auth0/auth0.service.spec.ts +++ b/apps/api/src/auth/services/auth0/auth0.service.spec.ts @@ -1,5 +1,7 @@ import { faker } from "@faker-js/faker"; import type { GetUsers200ResponseOneOfInner, ManagementClient } from "auth0"; +import type { Mock } from "vitest"; +import { vi } from "vitest"; import { Auth0Service } from "./auth0.service"; @@ -8,7 +10,7 @@ import { createAuth0User } from "@test/seeders"; describe(Auth0Service.name, () => { describe("createUser", () => { it("calls managementClient.users.create with verify_email false", async () => { - const create = jest.fn().mockResolvedValue({ data: {} }); + const create = vi.fn().mockResolvedValue({ data: {} }); const { auth0Service } = setup({ users: { create } }); await auth0Service.createUser({ @@ -28,7 +30,7 @@ describe(Auth0Service.name, () => { describe("sendVerificationEmail", () => { it("calls managementClient.jobs.verifyEmail with user ID", async () => { - const verifyEmail = jest.fn().mockResolvedValue(undefined); + const verifyEmail = vi.fn().mockResolvedValue(undefined); const { auth0Service } = setup({ jobs: { verifyEmail } }); await auth0Service.sendVerificationEmail("auth0|abc123"); @@ -39,7 +41,7 @@ describe(Auth0Service.name, () => { describe("markEmailVerified", () => { it("calls managementClient.users.update with email_verified true", async () => { - const update = jest.fn().mockResolvedValue(undefined); + const update = vi.fn().mockResolvedValue(undefined); const { auth0Service } = setup({ users: { update } }); await auth0Service.markEmailVerified("auth0|xyz789"); @@ -56,7 +58,7 @@ describe(Auth0Service.name, () => { email_verified: true }); - const mockGetByEmail = jest.fn().mockResolvedValue({ data: [mockUser] }); + const mockGetByEmail = vi.fn().mockResolvedValue({ data: [mockUser] }); const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -68,7 +70,7 @@ describe(Auth0Service.name, () => { it("returns null when no user is found", async () => { const email = faker.internet.email(); - const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const mockGetByEmail = vi.fn().mockResolvedValue({ data: [] }); const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -90,7 +92,7 @@ describe(Auth0Service.name, () => { }) ]; - const mockGetByEmail = jest.fn().mockResolvedValue({ data: mockUsers }); + const mockGetByEmail = vi.fn().mockResolvedValue({ data: mockUsers }); const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(email); @@ -100,7 +102,7 @@ describe(Auth0Service.name, () => { }); it("returns null for empty email string", async () => { - const mockGetByEmail = jest.fn().mockResolvedValue({ data: [] }); + const mockGetByEmail = vi.fn().mockResolvedValue({ data: [] }); const { auth0Service } = setup({ usersByEmail: { getByEmail: mockGetByEmail } }); const result = await auth0Service.getUserByEmail(""); @@ -112,9 +114,9 @@ describe(Auth0Service.name, () => { function setup( input: { - jobs?: { verifyEmail: jest.Mock }; - users?: { update?: jest.Mock; create?: jest.Mock }; - usersByEmail?: { getByEmail: jest.Mock }; + jobs?: { verifyEmail: Mock }; + users?: { update?: Mock; create?: Mock }; + usersByEmail?: { getByEmail: Mock }; } = {} ) { const mockManagementClient = { diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts index facd0ac477..815f7b0e6f 100644 --- a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts @@ -30,12 +30,13 @@ export class EmailVerificationCodeService { ) {} async sendCode(userInternalId: string): Promise<{ codeSentAt: string }> { - const user = await this.userRepository.findById(userInternalId); + const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); + const [user, recentCount] = await Promise.all([ + this.userRepository.findById(userInternalId), + this.emailVerificationCodeRepository.countRecentByUserId(userInternalId, since) + ]); assert(user, 404, "User not found"); assert(user.email, 400, "User has no email address"); - - const since = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); - const recentCount = await this.emailVerificationCodeRepository.countRecentByUserId(userInternalId, since); assert(recentCount < MAX_CODES_PER_WINDOW, 429, "Too many verification code requests. Please try again later."); const code = randomInt(100000, 1000000).toString(); diff --git a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap index 5104610e3f..7eeef183f5 100644 --- a/apps/api/test/functional/__snapshots__/docs.spec.ts.snap +++ b/apps/api/test/functional/__snapshots__/docs.spec.ts.snap @@ -15651,8 +15651,6 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = ` "data": { "properties": { "code": { - "maxLength": 6, - "minLength": 6, "pattern": "^\\d{6}$", "type": "string", }, From 248c38aaa3812315e575f7a5d66b38ce1cb55111 Mon Sep 17 00:00:00 2001 From: Maxime Beauchamp <15185355+baktun14@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:01:29 -0500 Subject: [PATCH 4/4] fix(auth): address review comments for verification code - Use @src/* path alias instead of relative import in spec file - Add try-catch with structured error logging around Auth0 markEmailVerified --- .../email-verification-code.service.spec.ts | 2 +- .../email-verification-code.service.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts index 32c6b711f3..d27bfe9fdb 100644 --- a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts @@ -9,10 +9,10 @@ import type { EmailVerificationCodeRepository } from "@src/auth/repositories/email-verification-code/email-verification-code.repository"; import type { Auth0Service } from "@src/auth/services/auth0/auth0.service"; +import { EmailVerificationCodeService } from "@src/auth/services/email-verification-code/email-verification-code.service"; import type { LoggerService } from "@src/core/providers/logging.provider"; import type { NotificationService } from "@src/notifications/services/notification/notification.service"; import type { UserRepository } from "@src/user/repositories/user/user.repository"; -import { EmailVerificationCodeService } from "./email-verification-code.service"; import { createUser } from "@test/seeders/user.seeder"; diff --git a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts index 815f7b0e6f..290abceadf 100644 --- a/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts +++ b/apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts @@ -59,7 +59,12 @@ export class EmailVerificationCodeService { async verifyCode(userInternalId: string, code: string): Promise { const auth0UserId = await this.verifyCodeInTransaction(userInternalId, code); - await this.auth0Service.markEmailVerified(auth0UserId); + try { + await this.auth0Service.markEmailVerified(auth0UserId); + } catch (error) { + this.logger.error({ event: "EMAIL_VERIFIED_MARK_AUTH0_FAILED", userId: userInternalId, auth0UserId, error }); + throw error; + } this.logger.info({ event: "EMAIL_VERIFIED_VIA_CODE", userId: userInternalId }); }