From 4444461ee307f8d9fbfa19fb05834b2cf81ae3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Wed, 27 May 2026 21:44:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20https://git.sabili?= =?UTF-8?q?n.com/EventHub/EventHubBack/issues/22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- include/records.hrl | 8 ++- src/core/core_user.erl | 6 +- src/core/core_verification.erl | 58 ++++++++++++++++ src/eventhub_app.erl | 2 + .../admin_handler_user_verification_token.erl | 35 ++++++++++ src/handlers/handler_login.erl | 2 + src/handlers/handler_register.erl | 24 +++---- src/handlers/handler_verify.erl | 67 +++++++++++++++++++ src/infra/infra_mnesia.erl | 5 +- src/logic/logic_auth.erl | 14 ++-- src/logic/logic_email.erl | 4 ++ src/swagger/eventhub_trails.erl | 28 ++++++++ test/api/api_test_runner.erl | 26 ++++++- test/api/users/user_refresh_tests.erl | 17 +++-- test/api/users/user_register_tests.erl | 44 ++++++------ test/api/users/user_verification_tests.erl | 62 +++++++++++++++++ test/api_users_SUITE.erl | 4 ++ 17 files changed, 358 insertions(+), 48 deletions(-) create mode 100644 src/core/core_verification.erl create mode 100644 src/handlers/admin/admin_handler_user_verification_token.erl create mode 100644 src/handlers/handler_verify.erl create mode 100644 src/logic/logic_email.erl create mode 100644 test/api/users/user_verification_tests.erl diff --git a/include/records.hrl b/include/records.hrl index e2f1bd6..f628c67 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -8,7 +8,7 @@ email :: binary(), password_hash :: binary(), role :: user | bot, - status :: active | frozen | deleted, + status :: pending | active | frozen | deleted, reason :: binary(), nickname :: binary(), avatar_url :: binary() | default, @@ -29,6 +29,12 @@ type :: access | refresh }). +-record(verification, { + token :: binary(), + user_id :: binary(), + expires_at :: calendar:datetime() +}). + %% ------------------- АДМИНИСТРАТОРЫ ------------------------------------ -record(admin, { id :: binary(), diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 86a72a8..d9c888d 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -42,7 +42,7 @@ create(Email, Password) -> email = Email, password_hash = PasswordHash, role = user, - status = active, + status = pending, reason = ?DEFAULT_REASON, nickname = extract_nickname(Email), avatar_url = ?DEFAULT_AVATAR_URL, @@ -302,9 +302,9 @@ apply_updates(User, Updates) -> -spec set_field(atom(), term(), #user{}) -> #user{}. set_field(email, Value, U) -> U#user{email = Value}; set_field(password_hash, Value, U) -> U#user{password_hash = Value}; -set_field(role, Value, U) when Value =:= user; Value =:= admin; Value =:= bot -> +set_field(role, Value, U) when Value =:= user; Value =:= bot -> U#user{role = Value}; -set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> +set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted; Value =:= pending -> U#user{status = Value}; set_field(reason, Value, U) -> U#user{reason = Value}; set_field(nickname, Value, U) -> U#user{nickname = Value}; diff --git a/src/core/core_verification.erl b/src/core/core_verification.erl new file mode 100644 index 0000000..ef3ff53 --- /dev/null +++ b/src/core/core_verification.erl @@ -0,0 +1,58 @@ +-module(core_verification). +-include("records.hrl"). +-export([create_token/1, verify_token/1, get_or_create_token/1, delete_token/1]). + +-define(TOKEN_LIFETIME_HOURS, 1). + +%%%------------------------------------------------------------------- +%%% @doc Создаёт верификационный токен для пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec create_token(UserId :: binary()) -> {ok, Token :: binary(), ExpiresAt :: calendar:datetime()}. +create_token(UserId) -> + Token = infra_utils:generate_id(32), + Expires = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + ?TOKEN_LIFETIME_HOURS * 3600), + mnesia:dirty_write(#verification{token = Token, user_id = UserId, expires_at = Expires}), + {ok, Token, Expires}. + +%%%------------------------------------------------------------------- +%%% @doc Проверяет токен. Возвращает `{ok, UserId}` или ошибку. +%%% @end +%%%------------------------------------------------------------------- +-spec verify_token(Token :: binary()) -> + {ok, UserId :: binary()} | {error, expired | not_found}. +verify_token(Token) -> + case mnesia:dirty_read(verification, Token) of + [#verification{user_id = UserId, expires_at = Expires}] -> + case Expires > calendar:universal_time() of + true -> {ok, UserId}; + false -> {error, expired} + end; + [] -> {error, not_found} + end. + +%%%------------------------------------------------------------------- +%%% @doc Возвращает существующий токен пользователя или создаёт новый. +%%% @end +%%%------------------------------------------------------------------- +-spec get_or_create_token(UserId :: binary()) -> + {ok, Token :: binary(), ExpiresAt :: calendar:datetime()} | {error, not_found}. +get_or_create_token(UserId) -> + case mnesia:dirty_match_object(#verification{user_id = UserId, _ = '_'}) of + [V] -> {ok, V#verification.token, V#verification.expires_at}; + [] -> + case core_user:get_by_id(UserId) of + {ok, _} -> create_token(UserId); + Error -> Error + end + end. + +%%%------------------------------------------------------------------- +%%% @doc Удаляет токен. +%%% @end +%%%------------------------------------------------------------------- +-spec delete_token(Token :: binary()) -> ok. +delete_token(Token) -> + mnesia:dirty_delete(verification, Token), + ok. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index c3c4a50..c16db52 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -66,6 +66,7 @@ start_http() -> {"/metrics/[:registry]", prometheus_cowboy2_handler, []}, {"/health", handler_health, []}, {"/v1/register", handler_register, []}, + {"/v1/verify", handler_verify, []}, {"/v1/login", handler_login, []}, {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, @@ -113,6 +114,7 @@ start_admin_http() -> % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, + {"/v1/admin/users/:id/verification-token", admin_handler_user_verification_token, []}, % ================== КАЛЕНДАРИ ================== {"/v1/admin/calendars", admin_handler_calendars, []}, {"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []}, diff --git a/src/handlers/admin/admin_handler_user_verification_token.erl b/src/handlers/admin/admin_handler_user_verification_token.erl new file mode 100644 index 0000000..8711fba --- /dev/null +++ b/src/handlers/admin/admin_handler_user_verification_token.erl @@ -0,0 +1,35 @@ +-module(admin_handler_user_verification_token). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_token(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +get_token(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + UserId = cowboy_req:binding(id, Req1), + case core_verification:get_or_create_token(UserId) of + {ok, Token, ExpiresAt} -> + handler_utils:send_json(Req1, 200, #{ + <<"token">> => Token, + <<"expires_at">> => handler_utils:datetime_to_iso8601(ExpiresAt) + }); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"User not found">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +trails() -> + [#{path => <<"/v1/admin/users/:id/verification-token">>, + method => <<"GET">>, + description => <<"Get or create verification token for user (admin)">>, + tags => [<<"Users">>], + parameters => [#{name => <<"id">>, in => <<"path">>, required => true, schema => #{type => string}}], + responses => #{200 => #{description => <<"Token">>}}}]. \ No newline at end of file diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index 9f4b4ac..6b0a478 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -86,6 +86,8 @@ login(Req) -> <<"refresh_token">> => RefreshToken }, handler_utils:send_json(Req1, 200, Response); + {error, not_verified} -> + handler_utils:send_error(Req1, 403, <<"Account not verified">>); {error, frozen} -> handler_utils:send_error(Req1, 403, <<"Account frozen">>); {error, deleted} -> diff --git a/src/handlers/handler_register.erl b/src/handlers/handler_register.erl index 58170a3..a5c0cc3 100644 --- a/src/handlers/handler_register.erl +++ b/src/handlers/handler_register.erl @@ -1,6 +1,7 @@ %%%------------------------------------------------------------------- %%% @doc Обработчик регистрации пользователя (клиентский API). -%%% POST – создаёт нового пользователя, возвращает JWT токен. +%%% POST – создаёт нового пользователя (статус pending), возвращает +%%% информацию о пользователе без JWT токена. %%% @end %%%------------------------------------------------------------------- -module(handler_register). @@ -23,7 +24,7 @@ trails() -> #{ path => <<"/v1/register">>, method => <<"POST">>, - description => <<"Register a new user">>, + description => <<"Register a new user (account will be pending until email verified)">>, tags => [<<"Auth">>], requestBody => #{ required => true, @@ -37,7 +38,7 @@ trails() -> }}} }, responses => #{ - 201 => #{description => <<"User registered, returns token and user info">>}, + 201 => #{description => <<"User registered successfully (pending verification)">>}, 400 => #{description => <<"Missing email or password, or invalid JSON">>}, 409 => #{description => <<"Email already exists">>} } @@ -74,17 +75,16 @@ register(Req) -> false -> case core_user:create(Email, Password) of {ok, User} -> - Token = logic_auth:generate_jwt( - User#user.id, - atom_to_binary(User#user.role, utf8) - ), + % Создаём верификационный токен и отправляем письмо + {ok, VerificationToken, _Expires} = core_verification:create_token(User#user.id), + logic_email:send_verification_email(Email, VerificationToken), Response = #{ user => #{ - id => User#user.id, - email => User#user.email, - role => User#user.role - }, - token => Token + id => User#user.id, + email => User#user.email, + role => atom_to_binary(User#user.role, utf8), + status => atom_to_binary(User#user.status, utf8) % <<"pending">> + } }, handler_utils:send_json(Req1, 201, Response); {error, email_exists} -> diff --git a/src/handlers/handler_verify.erl b/src/handlers/handler_verify.erl new file mode 100644 index 0000000..70dbf74 --- /dev/null +++ b/src/handlers/handler_verify.erl @@ -0,0 +1,67 @@ +%%%------------------------------------------------------------------- +%%% @doc Обработчик подтверждения email. +%%% POST /v1/verify – активирует аккаунт по верификационному токену. +%%% @end +%%%------------------------------------------------------------------- +-module(handler_verify). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +%%% cowboy_handler callback +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> + {ok, Body, Req1} = cowboy_req:read_body(Req), + try jsx:decode(Body, [return_maps]) of + #{<<"token">> := Token} -> + case core_verification:verify_token(Token) of + {ok, UserId} -> + core_user:update(UserId, [{status, active}]), + core_verification:delete_token(Token), + handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Account verified">>}); + {error, expired} -> + handler_utils:send_error(Req1, 410, <<"Token expired">>); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Token not found">>) + end; + _ -> + handler_utils:send_error(Req1, 400, <<"Missing token">>) + catch + _:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>) + end; + _ -> + handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger metadata +-spec trails() -> [map()]. +trails() -> + [ + #{ + path => <<"/v1/verify">>, + method => <<"POST">>, + description => <<"Verify user account using email verification token">>, + tags => [<<"Auth">>], + requestBody => #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + required => [<<"token">>], + properties => #{ + token => #{type => string, description => <<"Verification token received by email">>} + } + } + } + } + }, + responses => #{ + 200 => #{description => <<"Account verified successfully">>}, + 400 => #{description => <<"Missing token field or invalid JSON">>}, + 404 => #{description => <<"Token not found">>}, + 410 => #{description => <<"Token expired">>} + } + } + ]. \ No newline at end of file diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index dda29db..2b3e848 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -12,7 +12,7 @@ terminate/2, code_change/3]). -define(TABLES, [ - user, session, admin, admin_session, + user, session, verification, admin, admin_session, calendar, calendar_share, calendar_specialist, event, recurrence_exception, booking, @@ -22,7 +22,7 @@ stats, schema_migration ]). --define(DISC_TABLES, ?TABLES -- [session, admin_session]). +-define(DISC_TABLES, ?TABLES -- [session, verification, admin_session]). -define(TABLE_WAIT_TIMEOUT, 5000). -define(CLEANUP_INTERVAL, 30000). % 30 секунд @@ -219,6 +219,7 @@ table_opts(notification) -> [{disc_copies, [node()]}, {attributes, record_info(f table_opts(stats) -> [{disc_copies, [node()]}, {attributes, record_info(fields, stats)}]; table_opts(schema_migration) -> [{disc_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]; table_opts(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; +table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}]. %% =================================================================== diff --git a/src/logic/logic_auth.erl b/src/logic/logic_auth.erl index 22956e6..5f1256f 100644 --- a/src/logic/logic_auth.erl +++ b/src/logic/logic_auth.erl @@ -35,11 +35,15 @@ authenticate_user(Email, Password) -> {ok, User} -> case verify_password(Password, User#user.password_hash) of {ok, true} -> - {ok, #{ - id => User#user.id, - email => User#user.email, - role => atom_to_binary(User#user.role, utf8) - }}; + case User#user.status of + pending -> {error, not_verified}; + active -> {ok, #{ + id => User#user.id, + email => User#user.email, + role => atom_to_binary(User#user.role, utf8) + }}; + _ -> {error, invalid_credentials} + end; _ -> {error, invalid_credentials} end; {error, not_found} -> {error, invalid_credentials} diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl new file mode 100644 index 0000000..da99eb9 --- /dev/null +++ b/src/logic/logic_email.erl @@ -0,0 +1,4 @@ +-module(logic_email). +-export([send_verification_email/2]). +send_verification_email(Email, Token) -> + io:format("Sending verification email to ~s with token ~s~n", [Email, Token]). \ No newline at end of file diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 10f144d..db015ef 100644 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -1,15 +1,25 @@ -module(eventhub_trails). -export([admin/0, user/0, all/0]). +%%%------------------------------------------------------------------- +%%% @doc Возвращает список Swagger-трасс для административных эндпоинтов. +%%% @end +%%%------------------------------------------------------------------- +-spec admin() -> [map()]. admin() -> Modules = [ % ================== БАЗОВЫЕ ================== admin_handler_health, admin_handler_stats, admin_handler_login, + admin_handler_refresh, % ← добавлен % ================== ПОЛЬЗОВАТЕЛИ ================== admin_handler_users, admin_handler_user_by_id, + admin_handler_user_verification_token, % ← добавлен + % ================== КАЛЕНДАРИ ================== + admin_handler_calendars, % ← добавлен + admin_handler_calendar_by_id, % ← добавлен % ================== СОБЫТИЯ ================== admin_handler_events, admin_handler_event_by_id, @@ -33,16 +43,23 @@ admin() -> % ================== Управление ролями (только для superadmin) ================== admin_handler_me, admin_handler_admins, + admin_handler_admins_by_id, % ← добавлен admin_handler_audit ], lists:flatmap(fun trails_from_module/1, Modules). +%%%------------------------------------------------------------------- +%%% @doc Возвращает список Swagger-трасс для пользовательских эндпоинтов. +%%% @end +%%%------------------------------------------------------------------- +-spec user() -> [map()]. user() -> Modules = [ handler_health, handler_register, handler_login, handler_refresh, + handler_verify, % ← добавлен handler_booking_by_id, handler_bookings, handler_calendar_by_id, @@ -64,9 +81,20 @@ user() -> ], lists:flatmap(fun trails_from_module/1, Modules). +%%%------------------------------------------------------------------- +%%% @doc Возвращает полный список Swagger-трасс (административные + пользовательские). +%%% @end +%%%------------------------------------------------------------------- +-spec all() -> [map()]. all() -> admin() ++ user(). +%%%------------------------------------------------------------------- +%%% @doc Безопасно извлекает `trails/0` из модуля. +%%% Если функция отсутствует, возвращает пустой список. +%%% @end +%%%------------------------------------------------------------------- +-spec trails_from_module(atom()) -> [map()]. trails_from_module(Module) -> try Module:trails() of Trails when is_list(Trails) -> Trails diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index ee44343..0102b9a 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -40,6 +40,7 @@ client_put/3, client_delete/2, admin_patch/3]). +-export([verify_user/2]). %%%=================================================================== %%% Конфигурация окружения (CT_MODE, ...) @@ -281,10 +282,23 @@ future_date() -> -spec register_and_login(binary(), binary()) -> binary(). register_and_login(Email, Password) -> + % 1. Регистрируем пользователя Resp = client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{email => Email, password => Password})), {ok, 201, _, Body} = Resp, - #{<<"token">> := Token} = jsx:decode(list_to_binary(Body), [return_maps]), + #{<<"user">> := #{<<"id">> := UserId}} = jsx:decode(list_to_binary(Body), [return_maps]), + + % 2. Получаем админский токен (используется кеширование) + AdminToken = get_admin_token(), + + % 3. Верифицируем пользователя + verify_user(AdminToken, UserId), + + % 4. Логинимся и возвращаем свежий JWT + LoginResp = client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => Password})), + {ok, 200, _, LoginBody} = LoginResp, + #{<<"token">> := Token} = jsx:decode(list_to_binary(LoginBody), [return_maps]), Token. -spec create_calendar(binary(), map()) -> binary(). @@ -298,6 +312,16 @@ create_event(Token, CalId, Params) -> #{<<"id">> := EventId} = client_post(Path, Token, Params), EventId. +%% @doc Подтверждает email пользователя, используя админский эндпоинт +%% для получения верификационного токена и публичный /v1/verify. +-spec verify_user(AdminToken :: binary(), UserId :: binary()) -> ok. +verify_user(AdminToken, UserId) -> + #{<<"token">> := Token} = admin_get( + <<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken), + {ok, 200, _, _} = client_request(post, <<"/v1/verify">>, <<>>, + jsx:encode(#{<<"token">> => Token})), + ok. + %%%=================================================================== %%% Внутренние функции %%%=================================================================== diff --git a/test/api/users/user_refresh_tests.erl b/test/api/users/user_refresh_tests.erl index 414fd33..bdac955 100644 --- a/test/api/users/user_refresh_tests.erl +++ b/test/api/users/user_refresh_tests.erl @@ -22,7 +22,7 @@ -spec test() -> ok. test() -> ct:pal("=== User Refresh Tests ==="), - Token = api_test_runner:get_user_token(), + _Token = api_test_runner:get_user_token(), % Получаем refresh_token через логин (или регистрацию) Email = api_test_runner:unique_email(<<"refresh">>), @@ -76,13 +76,20 @@ test_missing_refresh_token() -> %%% Вспомогательные функции %%%=================================================================== -%% @doc Регистрирует пользователя, выполняет логин и возвращает refresh_token. +%% @doc Регистрирует пользователя, подтверждает email через админку +%% и возвращает результат логина (с refresh_token). -spec register_and_get_refresh(binary(), binary()) -> map(). register_and_get_refresh(Email, Password) -> - % Регистрируем - _ = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, + % Регистрируем пользователя + {ok, 201, _, RegBody} = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{email => Email, password => Password})), - % Логинимся, чтобы получить refresh_token + #{<<"user">> := #{<<"id">> := UserId}} = jsx:decode(list_to_binary(RegBody), [return_maps]), + + % Верифицируем аккаунт (получаем токен через админку и подтверждаем) + AdminToken = api_test_runner:get_admin_token(), + api_test_runner:verify_user(AdminToken, UserId), + + % Логинимся, чтобы получить access_token и refresh_token {ok, 200, _, Body} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, jsx:encode(#{email => Email, password => Password})), jsx:decode(list_to_binary(Body), [return_maps]). \ No newline at end of file diff --git a/test/api/users/user_register_tests.erl b/test/api/users/user_register_tests.erl index c31ba6b..a6b8ac1 100644 --- a/test/api/users/user_register_tests.erl +++ b/test/api/users/user_register_tests.erl @@ -2,34 +2,31 @@ %%% @doc Тесты клиентского API для регистрации пользователей. %%% %%% Покрывает эндпоинты: -%%% POST /v1/register +%%% POST /v1/register %%% %%% Проверяет: -%%% - успешную регистрацию нового пользователя -%%% - возврат JWT токена и данных пользователя -%%% - ошибку при повторной регистрации с тем же email -%%% - ошибку при отсутствии обязательных полей +%%% - успешную регистрацию нового пользователя (статус pending) +%%% - ошибку при повторной регистрации с тем же email +%%% - ошибку при отсутствии обязательных полей +%%% - что логин до подтверждения возвращает 403 %%% @end %%%------------------------------------------------------------------- -module(user_register_tests). -include_lib("eunit/include/eunit.hrl"). - -export([test/0]). %%%=================================================================== %%% Главная тестовая функция %%%=================================================================== - -spec test() -> ok. test() -> ct:pal("=== Client Register Tests ==="), Email = api_test_runner:unique_email(<<"register">>), Password = <<"StrongPass1!">>, - test_successful_register(Email, Password), test_duplicate_register(Email, Password), test_missing_fields(), - + test_login_before_verification(Email, Password), ct:pal("=== All client register tests passed ==="), ok. @@ -37,40 +34,49 @@ test() -> %%% Тестовые функции %%%=================================================================== -%% @doc Успешная регистрация: 201 Created, возвращает токен и пользователя. +%% @doc Успешная регистрация: 201 Created, возвращает пользователя со статусом pending. -spec test_successful_register(binary(), binary()) -> ok. test_successful_register(Email, Password) -> - ct:pal(" TEST: Successful registration"), + ct:pal(" TEST: Successful registration"), Resp = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{email => Email, password => Password})), {ok, 201, _, Body} = Resp, - #{<<"token">> := Token, <<"user">> := User} = jsx:decode(list_to_binary(Body), [return_maps]), - ?assert(is_binary(Token)), + #{<<"user">> := User} = jsx:decode(list_to_binary(Body), [return_maps]), ?assert(maps:is_key(<<"id">>, User)), ?assertEqual(Email, maps:get(<<"email">>, User)), - ct:pal(" OK: user ~s created", [maps:get(<<"id">>, User)]). + ?assertEqual(<<"pending">>, maps:get(<<"status">>, User)), + ct:pal(" OK: user ~s created with status pending", [maps:get(<<"id">>, User)]). %% @doc Повторная регистрация с тем же email: 409 Conflict. -spec test_duplicate_register(binary(), binary()) -> ok. test_duplicate_register(Email, Password) -> - ct:pal(" TEST: Duplicate registration"), + ct:pal(" TEST: Duplicate registration"), Resp = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{email => Email, password => Password})), {ok, 409, _, Body} = Resp, #{<<"error">> := ErrorMsg} = jsx:decode(list_to_binary(Body), [return_maps]), ?assertEqual(<<"Email already exists">>, ErrorMsg), - ct:pal(" OK: got 409 conflict"). + ct:pal(" OK: got 409 conflict"). %% @doc Отсутствие обязательных полей: 400 Bad Request. -spec test_missing_fields() -> ok. test_missing_fields() -> - ct:pal(" TEST: Missing required fields"), + ct:pal(" TEST: Missing required fields"), Resp1 = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{email => <<"missing@test.local">>})), ?assertMatch({ok, 400, _, _}, Resp1), - Resp2 = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, jsx:encode(#{password => <<"NoEmail1">>})), ?assertMatch({ok, 400, _, _}, Resp2), + ct:pal(" OK: 400 on missing fields"). - ct:pal(" OK: 400 on missing fields"). \ No newline at end of file +%% @doc Попытка входа до подтверждения email: 403 Forbidden. +-spec test_login_before_verification(binary(), binary()) -> ok. +test_login_before_verification(Email, Password) -> + ct:pal(" TEST: Login before email verification (403)"), + Resp = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => Password})), + {ok, 403, _, Body} = Resp, + #{<<"error">> := ErrorMsg} = jsx:decode(list_to_binary(Body), [return_maps]), + ?assertEqual(<<"Account not verified">>, ErrorMsg), + ct:pal(" OK: got 403"). \ No newline at end of file diff --git a/test/api/users/user_verification_tests.erl b/test/api/users/user_verification_tests.erl new file mode 100644 index 0000000..aeaba88 --- /dev/null +++ b/test/api/users/user_verification_tests.erl @@ -0,0 +1,62 @@ +%%%------------------------------------------------------------------- +%%% @doc Тесты процесса подтверждения регистрации. +%%% +%%% Проверяет: +%%% - регистрация создаёт пользователя со статусом `pending` +%%% - вход до подтверждения возвращает 403 +%%% - получение верификационного токена через админку +%%% - подтверждение аккаунта через публичный эндпоинт +%%% - после подтверждения вход успешен +%%% - повторное использование токена возвращает ошибку +%%% - невалидный токен возвращает 404 +%%% @end +%%%------------------------------------------------------------------- +-module(user_verification_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). +-export([verification_test/0]). + +verification_test() -> test(). + +-spec test() -> ok. +test() -> + ct:pal("=== User Verification Tests ==="), + AdminToken = api_test_runner:get_admin_token(), + Email = api_test_runner:unique_email(<<"verify">>), + Password = <<"TestPass123">>, + + % 1. Регистрируем пользователя (должен получить статус pending) + {ok, 201, _, RegBody} = api_test_runner:client_request(post, <<"/v1/register">>, <<>>, + jsx:encode(#{email => Email, password => Password})), + #{<<"user">> := #{<<"id">> := UserId, <<"status">> := UserStatus}} = + jsx:decode(list_to_binary(RegBody), [return_maps]), + ?assertEqual(<<"pending">>, UserStatus), + + % 2. Попытка входа до подтверждения – должна вернуть 403 + {ok, 403, _, _} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => Password})), + + % 3. Получаем верификационный токен через админку + #{<<"token">> := Token} = api_test_runner:admin_get( + <<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken), + + % 4. Подтверждаем аккаунт через публичный эндпоинт + {ok, 200, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>, + jsx:encode(#{<<"token">> => Token})), + + % 5. Теперь вход должен быть успешным + {ok, 200, _, LoginBody} = api_test_runner:client_request(post, <<"/v1/login">>, <<>>, + jsx:encode(#{email => Email, password => Password})), + #{<<"token">> := AuthToken} = jsx:decode(list_to_binary(LoginBody), [return_maps]), + ?assert(is_binary(AuthToken)), + + % 6. Повторное использование того же токена – ошибка 404 + {ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>, + jsx:encode(#{<<"token">> => Token})), + + % 7. Невалидный токен – ошибка 404 + {ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>, + jsx:encode(#{<<"token">> => <<"invalid_token">>})), + + ct:pal("=== All user verification tests passed ==="), + ok. \ No newline at end of file diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index 7e9f6be..a87cef2 100644 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -25,6 +25,7 @@ %% @doc Возвращает список тестовых кейсов. all() -> [ + user_test_verification, user_test_register, user_test_login, user_test_user_me, @@ -91,6 +92,9 @@ end_per_suite(Config) -> %%%=================================================================== %% @doc Тесты регистрации. +user_test_verification(_Config) -> + user_verification_tests:test(). + user_test_register(_Config) -> user_register_tests:test().