Перенести все админские эндпоинты на порт 8445 и добавить отдельную авторизацию для админов. Часть 2. Final #3

This commit is contained in:
2026-04-28 12:42:10 +03:00
parent 4ed6a961ab
commit 7ea4efd7d9
38 changed files with 1252 additions and 1124 deletions

View File

@@ -33,7 +33,8 @@ compile: ## Скомпилировать проект
clean: ## Очистить проект clean: ## Очистить проект
@echo "Очистка проекта..." @echo "Очистка проекта..."
@$(REBAR3) clean @$(REBAR3) clean
@rm -rf _build build deps logs *.log #@rm -rf _build build/ct_run.* deps logs *.log
@rm -rf build/ct_run.* deps logs *.log
@echo "✓ Очистка завершена" @echo "✓ Очистка завершена"
deps: ## Установить зависимости deps: ## Установить зависимости
@@ -61,7 +62,7 @@ test-server: ## Запустить тестовый сервер в фоне
@echo "Cleaning old data..." @echo "Cleaning old data..."
@rm -rf Mnesia.* @rm -rf Mnesia.*
@echo "Starting server..." @echo "Starting server..."
@rebar3 shell --sname eventhub_test </dev/null > /tmp/eventhub_test.log 2>&1 & @$(REBAR3) shell --sname eventhub_test </dev/null > /tmp/eventhub_test.log 2>&1 &
@echo "PID: $$!" @echo "PID: $$!"
@for i in 1 2 3 4 5 6 7 8 9 10; do \ @for i in 1 2 3 4 5 6 7 8 9 10; do \
if curl -s http://localhost:8080/health | grep -q "ok"; then \ if curl -s http://localhost:8080/health | grep -q "ok"; then \
@@ -96,66 +97,23 @@ eunit-verbose: ## Запустить EUnit тесты с подробным вы
@echo "Запуск EUnit тестов (verbose)..." @echo "Запуск EUnit тестов (verbose)..."
@$(REBAR3) eunit --sname $(SNAME)_test --verbose @$(REBAR3) eunit --sname $(SNAME)_test --verbose
test-search-unit: ## Запустить unit-тесты поиска
@echo "Запуск unit-тестов поиска (logic)..."
@$(REBAR3) eunit --sname test_search1 --module=logic_search_tests
test-search-handler: ## Запустить handler тесты поиска
@echo "Запуск handler тестов поиска..."
@$(REBAR3) eunit --sname test_search2 --module=handler_search_tests
test-api: test-ct test-api: test-ct
test-ct: ## Запустить Common Test для API test-ct: ## Запустить Common Test для API
@rebar3 ct --sname $(SNAME)_api_test @$(REBAR3) ct --sname $(SNAME)_api_test
test-ct-verbose: ## Запустить Common Test с подробным выводом test-ct-verbose: ## Запустить Common Test с подробным выводом
@ct_run -suite test/ct/api_SUITE \ @ct_run -suite test/api_SUITE \
-pa _build/default/lib/*/ebin \ -pa _build/default/lib/*/ebin \
-pa test/ct/api \ -pa test/api \
-logdir logs/ct \ -logdir build \
-verbosity 50 -verbosity 50
test-api-auth: ## Тесты аутентификации
@rebar3 shell --eval "api_auth_tests:test()." --name test_api@127.0.0.1
test-api-calendar: ## Тесты календарей
@rebar3 shell --eval "api_calendar_tests:test()." --name test_api@127.0.0.1
test-api-event: ## Тесты событий
@rebar3 shell --eval "api_event_tests:test()." --name test_api@127.0.0.1
test-api-booking: ## Тесты бронирований
@rebar3 shell --eval "api_booking_tests:test()." --name test_api@127.0.0.1
test-api-search: ## Тесты поиска
@rebar3 shell --eval "api_search_tests:test()." --name test_api@127.0.0.1
test-api-reviews: ## Тесты отзывов
@rebar3 shell --eval "api_reviews_tests:test()." --name test_api@127.0.0.1
test-api-moderation: ## Тесты модерации
@rebar3 shell --eval "api_moderation_tests:test()." --name test_api@127.0.0.1
test-api-tickets: ## Тесты тикетов
@rebar3 shell --eval "api_tickets_tests:test()." --name test_api@127.0.0.1
test-api-subscription: ## Тесты подписки
@rebar3 shell --eval "api_subscription_tests:test()." --name test_api@127.0.0.1
test-api-admin: ## Тесты админки
@rebar3 shell --eval "api_admin_tests:test()." --name test_api@127.0.0.1
test-api-ws: ## Тесты админки
@rebar3 shell --eval "api_websocket_tests:test()." --name test_api@127.0.0.1
test-scripts: ## Запустить тесты с фильтром (make test-runner PATTERN=booking) test-scripts: ## Запустить тесты с фильтром (make test-runner PATTERN=booking)
@chmod +x test/scripts/*.sh @chmod +x test/scripts/*.sh
@cd test/scripts && ./run_tests.sh $(PATTERN) @cd test/scripts && ./run_tests.sh $(PATTERN)
test-all: eunit ## Запустить ВСЕ тесты (EUnit + API) test-all: eunit test-api ## Запустить ВСЕ тесты (EUnit + API)
@sleep 1
make test-api
@echo "========================================" @echo "========================================"
@echo " ВСЕ ТЕСТЫ ПРОЙДЕНЫ!" @echo " ВСЕ ТЕСТЫ ПРОЙДЕНЫ!"
@echo "========================================" @echo "========================================"

View File

@@ -126,6 +126,7 @@
%% ------------------- Баг-трекер -------------------------------------- %% ------------------- Баг-трекер --------------------------------------
-record(ticket, { -record(ticket, {
id :: binary(), id :: binary(),
reporter_id :: binary(),
error_hash :: binary(), error_hash :: binary(),
error_message :: binary(), error_message :: binary(),
stacktrace :: binary(), stacktrace :: binary(),

View File

@@ -44,7 +44,7 @@
{ct_opts, [ {ct_opts, [
{src_dirs, ["src", "test/api"]}, {src_dirs, ["src", "test/api"]},
{sys_config, ["config/sys.config"]}, % Load app config {sys_config, ["config/sys.config"]}, % Load app config
{logdir, "build"}, % Where to put HTML reports {logdir, "_build/test/ct"}, % Where to put HTML reports
{verbose, true} % Print more info to console {verbose, true} % Print more info to console
]}. ]}.
@@ -52,5 +52,3 @@
{i, "include"}, % Include directory {i, "include"}, % Include directory
{d, 'DEBUG'} % Define macros {d, 'DEBUG'} % Define macros
]}. ]}.
{eunit_opts, [verbose]}.

View File

@@ -4,6 +4,12 @@
-export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). -export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]).
-export([update_status/2, check_expired/0]). -export([update_status/2, check_expired/0]).
-export([generate_id/0]). -export([generate_id/0]).
% --------------- новые обёртки для админки ------------------
-export([list_subscriptions/0,
create_subscription/1,
update_subscription/2,
delete_subscription/1
]).
-define(TRIAL_DAYS, 30). -define(TRIAL_DAYS, 30).
@@ -141,3 +147,71 @@ add_months(DateTime, Months) ->
add_days(DateTime, Days) -> add_days(DateTime, Days) ->
Seconds = calendar:datetime_to_gregorian_seconds(DateTime), Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)). calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
% ================================================================
% Новые обёртки для совместимости с admin_handler_subscriptions
% ================================================================
list_subscriptions() ->
{ok, Subs} = list_all(),
Subs.
create_subscription(Data) ->
UserId = maps:get(<<"user_id">>, Data),
Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of
<<"monthly">> -> monthly;
<<"yearly">> -> yearly;
<<"quarterly">>-> quarterly;
<<"biannual">> -> biannual;
<<"annual">> -> annual;
Other -> Other
end,
TrialUsed = maps:get(<<"trial_used">>, Data, false),
create(UserId, Plan, TrialUsed).
update_subscription(Id, Updates) ->
case get_by_id(Id) of
{ok, Sub} ->
Updated = apply_updates(Sub, Updates),
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
delete_subscription(Id) ->
case get_by_id(Id) of
{ok, _Sub} ->
mnesia:dirty_delete({subscription, Id}),
{ok, deleted};
Error -> Error
end.
%% Применение обновлений к записи подписки
apply_updates(Sub, Updates) ->
lists:foldl(fun({Key, Value}, Acc) ->
case Key of
<<"status">> ->
NewStatus = case Value of
<<"active">> -> active;
<<"cancelled">> -> cancelled;
<<"expired">> -> expired;
Other -> Other
end,
Acc#subscription{status = NewStatus, updated_at = calendar:universal_time()};
<<"plan">> ->
NewPlan = case Value of
<<"monthly">> -> monthly;
<<"yearly">> -> yearly;
<<"quarterly">>-> quarterly;
<<"biannual">> -> biannual;
<<"annual">> -> annual;
Other -> Other
end,
Acc#subscription{plan = NewPlan, updated_at = calendar:universal_time()};
<<"trial_used">> ->
Acc#subscription{trial_used = Value, updated_at = calendar:universal_time()};
<<"expires_at">> ->
Acc#subscription{expires_at = Value, updated_at = calendar:universal_time()};
_ -> Acc
end
end, Sub, maps:to_list(Updates)).

View File

@@ -1,143 +1,86 @@
-module(core_ticket). -module(core_ticket).
-include("records.hrl"). -include("records.hrl").
-export([list_all/0,
get_by_id/1,
update_ticket/2,
delete_ticket/1,
stats/0,
create_ticket/1,
list_by_user/1]).
-export([create_or_update/3, get_by_id/1, get_by_error_hash/1, list_all/0, list_by_status/1]). list_all() ->
-export([update_status/2, assign/2, add_resolution/2]). mnesia:dirty_match_object(#ticket{_ = '_'}).
-export([generate_id/0, generate_error_hash/2]).
%% Создать или обновить тикет (группировка по хэшу ошибки) get_by_id(Id) ->
create_or_update(ErrorMessage, Stacktrace, Context) -> case mnesia:dirty_read({ticket, Id}) of
ErrorHash = generate_error_hash(ErrorMessage, Stacktrace), [Ticket] -> {ok, Ticket};
case get_by_error_hash(ErrorHash) of [] -> {error, not_found}
{error, not_found} -> end.
% Создаём новый тикет
Id = generate_id(), update_ticket(Id, Updates) ->
case get_by_id(Id) of
{ok, Ticket} ->
Updated = apply_updates(Ticket, Updates),
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
delete_ticket(Id) ->
case get_by_id(Id) of
{ok, _Ticket} -> % переменная не используется
mnesia:dirty_delete({ticket, Id}),
{ok, deleted};
Error -> Error
end.
stats() ->
Tickets = list_all(),
#{
total => length(Tickets),
open => count_by_status(open, Tickets),
in_progress => count_by_status(in_progress, Tickets),
resolved => count_by_status(resolved, Tickets),
closed => count_by_status(closed, Tickets)
}.
%% ── новые функции ──────────────────────────────────────
create_ticket(Data) ->
Id = base64:encode(crypto:strong_rand_bytes(9)),
Now = calendar:universal_time(), Now = calendar:universal_time(),
Ticket = #ticket{ Ticket = #ticket{
id = Id, id = Id,
error_hash = ErrorHash, reporter_id = maps:get(<<"reporter_id">>, Data, undefined),
error_message = ErrorMessage, error_hash = maps:get(<<"error_hash">>, Data, <<"">>),
stacktrace = Stacktrace, error_message = maps:get(<<"error_message">>, Data),
context = term_to_binary(Context), stacktrace = maps:get(<<"stacktrace">>, Data, <<"">>),
context = maps:get(<<"context">>, Data, <<"">>),
count = 1, count = 1,
first_seen = Now, first_seen = Now,
last_seen = Now, last_seen = Now,
status = open, status = maps:get(<<"status">>, Data, open),
assigned_to = undefined, assigned_to = maps:get(<<"assigned_to">>, Data, undefined),
resolution_note = undefined resolution_note = maps:get(<<"resolution_note">>, Data, undefined)
}, },
F = fun() -> mnesia:dirty_write(Ticket),
mnesia:write(Ticket), {ok, Ticket}.
{ok, Ticket}
end, list_by_user(UserId) ->
case mnesia:transaction(F) of mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}).
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason} %% ── внутренние ─────────────────────────────────────────
end; apply_updates(Ticket, Updates) ->
{ok, Ticket} -> lists:foldl(fun({Key, Value}, Acc) ->
% Обновляем существующий case Key of
F = fun() -> <<"status">> -> Acc#ticket{status = binary_to_atom(Value, utf8)};
Updated = Ticket#ticket{ <<"assigned_to">> -> Acc#ticket{assigned_to = Value};
count = Ticket#ticket.count + 1, <<"resolution_note">> -> Acc#ticket{resolution_note = Value};
last_seen = calendar:universal_time() <<"error_message">> -> Acc#ticket{error_message = Value};
}, <<"stacktrace">> -> Acc#ticket{stacktrace = Value};
mnesia:write(Updated), <<"context">> -> Acc#ticket{context = Value};
{ok, Updated} _ -> Acc
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end end
end. end, Ticket, maps:to_list(Updates)).
%% Получение тикета по ID count_by_status(Status, Tickets) ->
get_by_id(Id) -> length([T || T <- Tickets, T#ticket.status =:= Status]).
case mnesia:dirty_read(ticket, Id) of
[] -> {error, not_found};
[Ticket] -> {ok, Ticket}
end.
%% Получение тикета по хэшу ошибки
get_by_error_hash(ErrorHash) ->
Match = #ticket{error_hash = ErrorHash, _ = '_'},
case mnesia:dirty_match_object(Match) of
[] -> {error, not_found};
[Ticket] -> {ok, Ticket}
end.
%% Список всех тикетов
list_all() ->
Match = #ticket{_ = '_'},
Tickets = mnesia:dirty_match_object(Match),
{ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}.
%% Список тикетов по статусу
list_by_status(Status) ->
Match = #ticket{status = Status, _ = '_'},
Tickets = mnesia:dirty_match_object(Match),
{ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}.
%% Обновление статуса тикета
update_status(Id, Status) when Status =:= open; Status =:= in_progress; Status =:= resolved; Status =:= closed ->
F = fun() ->
case mnesia:read(ticket, Id) of
[] ->
{error, not_found};
[Ticket] ->
Updated = Ticket#ticket{status = Status},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%% Назначить тикет администратору
assign(Id, AdminId) ->
F = fun() ->
case mnesia:read(ticket, Id) of
[] ->
{error, not_found};
[Ticket] ->
Updated = Ticket#ticket{
assigned_to = AdminId,
status = case Ticket#ticket.status of
open -> in_progress;
S -> S
end
},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%% Добавить примечание о решении
add_resolution(Id, Note) ->
F = fun() ->
case mnesia:read(ticket, Id) of
[] ->
{error, not_found};
[Ticket] ->
Updated = Ticket#ticket{resolution_note = Note},
mnesia:write(Updated),
{ok, Updated}
end
end,
case mnesia:transaction(F) of
{atomic, Result} -> Result;
{aborted, Reason} -> {error, Reason}
end.
%% Внутренние функции
generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).
generate_error_hash(ErrorMessage, Stacktrace) ->
Data = iolist_to_binary([ErrorMessage, "\n", Stacktrace]),
base64:encode(crypto:hash(sha256, Data), #{mode => urlsafe, padding => false}).

View File

@@ -5,6 +5,7 @@
-export([email_exists/1]). -export([email_exists/1]).
-export([generate_id/0]). -export([generate_id/0]).
-export([list_users/0]). -export([list_users/0]).
-export([block/1, unblock/1]).
%% Создание пользователя %% Создание пользователя
create(Email, Password) -> create(Email, Password) ->
@@ -103,6 +104,24 @@ user_to_map(User) ->
updated_at => User#user.updated_at updated_at => User#user.updated_at
}. }.
block(Id) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = blocked, updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
unblock(Id) ->
case get_by_id(Id) of
{ok, User} ->
Updated = User#user{status = active, updated_at = calendar:universal_time()},
mnesia:dirty_write(Updated),
{ok, Updated};
Error -> Error
end.
%% Внутренние функции %% Внутренние функции
generate_id() -> generate_id() ->
base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}).

View File

@@ -50,6 +50,7 @@ start_http() ->
{"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reviews/:id", handler_review_by_id, []},
{"/v1/reports", handler_reports, []}, {"/v1/reports", handler_reports, []},
{"/v1/tickets", handler_tickets, []}, {"/v1/tickets", handler_tickets, []},
{"/v1/tickets/:id", handler_ticket_by_id, []},
{"/v1/subscription", handler_subscription, []} {"/v1/subscription", handler_subscription, []}
]} ]}
]), ]),

View File

@@ -10,7 +10,7 @@ init(Req0, State) ->
{ok, Body, Req1} = cowboy_req:read_body(Req0), {ok, Body, Req1} = cowboy_req:read_body(Req0),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} -> #{<<"email">> := Email, <<"password">> := Password} ->
case auth:authenticate_admin_request(Req1, Email, Password) of case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of
{ok, Token, User} -> {ok, Token, User} ->
Resp = jsx:encode(#{ Resp = jsx:encode(#{
<<"token">> => Token, <<"token">> => Token,

View File

@@ -39,7 +39,8 @@ update_report(Req) ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"status">> := NewStatus} -> #{<<"status">> := NewStatus} ->
case core_report:update_status(ReportId, NewStatus) of StatusAtom = binary_to_atom(NewStatus, utf8),
case core_report:update_status(ReportId, StatusAtom, AdminId) of
{ok, Report} -> {ok, Report} ->
send_json(Req2, 200, report_to_json(Report)); send_json(Req2, 200, report_to_json(Report));
{error, not_found} -> {error, not_found} ->

View File

@@ -2,7 +2,7 @@
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-include("records.hrl"). %% ← обязательно для #user{} и #report{} -include("records.hrl").
init(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
@@ -16,7 +16,7 @@ list_reports(Req) ->
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
Reports = core_report:list_reports(), {ok, Reports} = core_report:list_all(),
send_json(Req1, 200, [report_to_json(R) || R <- Reports]); send_json(Req1, 200, [report_to_json(R) || R <- Reports]);
false -> false ->
send_error(Req1, 403, <<"Admin access required">>) send_error(Req1, 403, <<"Admin access required">>)
@@ -34,7 +34,7 @@ update_report(Req) ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"status">> := NewStatus} -> #{<<"status">> := NewStatus} ->
case core_report:update_status(ReportId, NewStatus) of case core_report:update_status(ReportId, NewStatus, AdminId) of
{ok, Report} -> {ok, Report} ->
send_json(Req2, 200, report_to_json(Report)); send_json(Req2, 200, report_to_json(Report));
{error, not_found} -> {error, not_found} ->

View File

@@ -39,7 +39,7 @@ list_subscriptions(Req) ->
create_subscription(Req) -> create_subscription(Req) ->
case auth_admin(Req) of case auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"user_id">> := _UserId} = Data -> #{<<"user_id">> := _UserId} = Data ->

View File

@@ -28,7 +28,7 @@ handle_item(TicketId, Req) ->
list_tickets(Req) -> list_tickets(Req) ->
case auth_admin(Req) of case auth_admin(Req) of
{ok, _AdminId, Req1} -> {ok, _AdminId, Req1} ->
Tickets = core_ticket:list_tickets(), Tickets = core_ticket:list_all(), % ← было list_tickets()
send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]);
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) send_error(Req1, Code, Message)
@@ -36,7 +36,7 @@ list_tickets(Req) ->
create_ticket(Req) -> create_ticket(Req) ->
case auth_admin(Req) of case auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, _AdminId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"error_message">> := _} = Data -> #{<<"error_message">> := _} = Data ->

View File

@@ -1,6 +1,5 @@
-module(admin_ws_handler). -module(admin_ws_handler).
-behaviour(cowboy_websocket). -behaviour(cowboy_websocket).
-export([init/2]). -export([init/2]).
-export([websocket_init/1]). -export([websocket_init/1]).
-export([websocket_handle/2]). -export([websocket_handle/2]).
@@ -21,15 +20,13 @@ init(Req, _Opts) ->
Token -> Token ->
io:format("[ADMIN_WS] Token received: ~s...~n", [binary_part(Token, 0, 30)]), io:format("[ADMIN_WS] Token received: ~s...~n", [binary_part(Token, 0, 30)]),
case logic_auth:verify_jwt(Token) of case logic_auth:verify_jwt(Token) of
{ok, Claims} -> {ok, UserId, Role} ->
UserId = maps:get(<<"user_id">>, Claims),
Role = maps:get(<<"role">>, Claims),
io:format("[ADMIN_WS] UserId: ~s, Role: ~s~n", [UserId, Role]), io:format("[ADMIN_WS] UserId: ~s, Role: ~s~n", [UserId, Role]),
case Role of case is_admin_role(Role) of
<<"admin">> -> true ->
io:format("[ADMIN_WS] Admin access granted~n"), io:format("[ADMIN_WS] Admin access granted~n"),
{cowboy_websocket, Req, #state{admin_id = UserId}}; {cowboy_websocket, Req, #state{admin_id = UserId}};
_ -> false ->
io:format("[ADMIN_WS] Access denied: not admin~n"), io:format("[ADMIN_WS] Access denied: not admin~n"),
Resp = cowboy_req:reply(403, #{}, <<"Admin access required">>, Req), Resp = cowboy_req:reply(403, #{}, <<"Admin access required">>, Req),
{ok, Resp, undefined} {ok, Resp, undefined}
@@ -85,3 +82,6 @@ websocket_info(_Info, State) ->
terminate(_Reason, _Req, _State) -> terminate(_Reason, _Req, _State) ->
pg:leave(eventhub_admin_ws, self()), pg:leave(eventhub_admin_ws, self()),
ok. ok.
is_admin_role(Role) ->
lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]).

View File

@@ -8,8 +8,7 @@ authenticate(Req) ->
{bearer, Token} -> {bearer, Token} ->
io:format("[AUTH] Bearer token found: ~s...~n", [binary_part(Token, 0, 30)]), io:format("[AUTH] Bearer token found: ~s...~n", [binary_part(Token, 0, 30)]),
case logic_auth:verify_jwt(Token) of case logic_auth:verify_jwt(Token) of
{ok, Claims} -> {ok, UserId, _Role} ->
UserId = maps:get(<<"user_id">>, Claims),
io:format("[AUTH] JWT verified, UserId: ~s~n", [UserId]), io:format("[AUTH] JWT verified, UserId: ~s~n", [UserId]),
{ok, UserId, Req}; {ok, UserId, Req};
{error, expired} -> {error, expired} ->

View File

@@ -19,9 +19,9 @@ handle(Req, _Opts) ->
_ -> _ ->
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} -> #{<<"email">> := Email, <<"password">> := Password} ->
case auth:authenticate_user_request(Req1, Email, Password) of case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
{ok, Token, User} -> {ok, Token, User} ->
{RefreshToken, ExpiresAt} = auth:generate_refresh_token(maps:get(id, User)), {RefreshToken, ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)),
save_refresh_token(maps:get(id, User), RefreshToken, ExpiresAt), save_refresh_token(maps:get(id, User), RefreshToken, ExpiresAt),
Response = #{ Response = #{
user => #{ user => #{

View File

@@ -1,91 +1,67 @@
-module(handler_refresh). -module(handler_refresh).
-include("records.hrl"). -include("records.hrl").
-export([init/2]). -export([init/2]).
init(Req, Opts) -> init(Req0, _Opts) ->
handle(Req, Opts). case cowboy_req:method(Req0) of
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> <<"POST">> ->
{ok, Body, Req1} = cowboy_req:read_body(Req), {ok, Body, Req1} = cowboy_req:read_body(Req0),
case jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} -> #{<<"refresh_token">> := RefreshToken} ->
case validate_refresh_token(RefreshToken) of case get_session(RefreshToken) of
{ok, UserId} ->
case core_user:get_by_id(UserId) of
{ok, User} ->
% Генерируем новые токены
NewToken = logic_auth:generate_jwt(User#user.id, User#user.role),
{NewRefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id),
% Сохраняем новый refresh token
save_refresh_token(User#user.id, NewRefreshToken, ExpiresAt),
% Удаляем старый refresh token
delete_refresh_token(RefreshToken),
Response = #{
token => NewToken,
refresh_token => NewRefreshToken
},
send_json(Req1, 200, Response);
{error, not_found} ->
send_error(Req1, 401, <<"User not found">>)
end;
{error, expired} ->
send_error(Req1, 401, <<"Refresh token expired">>);
{error, invalid} ->
send_error(Req1, 401, <<"Invalid refresh token">>)
end;
_ ->
send_error(Req1, 400, <<"Missing refresh_token">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end.
validate_refresh_token(Token) ->
case get_session_by_token(Token) of
{ok, Session} -> {ok, Session} ->
% Проверяем срок действия % Проверяем, не истекла ли сессия
Now = calendar:universal_time(), case Session#session.expires_at > calendar:universal_time() of
case Session#session.expires_at > Now of true ->
true -> {ok, Session#session.user_id}; % Генерируем новый access-токен и refresh-токен
false -> {error, expired} User = get_user(Session#session.user_id),
end; NewToken = eventhub_auth:generate_user_token(
{error, not_found} -> User#user.id,
{error, invalid} atom_to_binary(User#user.role, utf8)
end. ),
{NewRefreshToken, ExpiresAt} =
get_session_by_token(Token) -> eventhub_auth:generate_refresh_token(User#user.id),
Match = #session{token = Token, type = refresh, _ = '_'}, % Удаляем старую сессию и сохраняем новую
case mnesia:dirty_match_object(Match) of mnesia:dirty_delete_object(Session),
[] -> {error, not_found}; NewSession = #session{
[Session] -> {ok, Session} token = NewRefreshToken,
end. user_id = User#user.id,
save_refresh_token(UserId, Token, ExpiresAt) ->
Session = #session{
token = Token,
user_id = UserId,
expires_at = ExpiresAt, expires_at = ExpiresAt,
type = refresh type = refresh
}, },
mnesia:dirty_write(Session). mnesia:dirty_write(NewSession),
Resp = jsx:encode(#{
delete_refresh_token(Token) -> token => NewToken,
Match = #session{token = Token, type = refresh, _ = '_'}, refresh_token => NewRefreshToken
case mnesia:dirty_match_object(Match) of }),
[] -> ok; cowboy_req:reply(200, #{
[Session] -> mnesia:dirty_delete_object(Session) <<"content-type">> => <<"application/json">>
}, Resp, Req1);
false ->
mnesia:dirty_delete_object(Session),
send_error(Req1, 401, <<"Refresh token expired">>)
end;
{error, not_found} ->
send_error(Req1, 401, <<"Refresh token not found">>)
end;
_ ->
send_error(Req1, 400, <<"Missing refresh_token field">>)
catch
_:_ -> send_error(Req1, 400, <<"Invalid JSON">>)
end;
_ ->
send_error(Req0, 405, <<"Method not allowed">>)
end. end.
send_json(Req, Status, Data) -> get_session(Token) ->
Body = jsx:encode(Data), case mnesia:dirty_read({session, Token}) of
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), [Session] -> {ok, Session};
{ok, Body, []}. [] -> {error, not_found}
end.
get_user(UserId) ->
[User] = mnesia:dirty_read({user, UserId}),
User.
send_error(Req, Status, Message) -> send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}), Body = jsx:encode(#{error => Message}),

View File

@@ -24,7 +24,7 @@ handle(Req, _Opts) ->
false -> false ->
case core_user:create(Email, Password) of case core_user:create(Email, Password) of
{ok, User} -> {ok, User} ->
Token = logic_auth:generate_jwt(User#user.id, User#user.role), Token = logic_auth:generate_jwt(User#user.id, atom_to_binary(User#user.role, utf8)),
Response = #{ Response = #{
user => #{ user => #{
id => User#user.id, id => User#user.id,

View File

@@ -1,157 +1,93 @@
-module(handler_ticket_by_id). -module(handler_ticket_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
init(Req, Opts) -> -include("records.hrl").
handle(Req, Opts).
handle(Req, _Opts) -> init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_ticket(Req); <<"GET">> -> get_ticket(Req);
<<"PUT">> -> update_ticket(Req); <<"PUT">> -> update_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/admin/tickets/:id - получить тикет
get_ticket(Req) -> get_ticket(Req) ->
case handler_auth:authenticate(Req) of case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} -> {ok, UserId, Req1} ->
TicketId = cowboy_req:binding(id, Req1), TicketId = cowboy_req:binding(id, Req1),
case logic_ticket:get_ticket(AdminId, TicketId) of io:format("[TICKET_BY_ID] User ~s requests ticket ~s~n", [UserId, TicketId]),
case core_ticket:get_by_id(TicketId) of
{ok, Ticket} -> {ok, Ticket} ->
Response = ticket_to_json(Ticket), io:format("[TICKET_BY_ID] Found ticket, reporter_id: ~s~n", [Ticket#ticket.reporter_id]),
send_json(Req1, 200, Response); case is_admin(UserId) orelse Ticket#ticket.reporter_id =:= UserId of
{error, access_denied} -> true ->
send_error(Req1, 403, <<"Admin access required">>); io:format("[TICKET_BY_ID] Access granted~n"),
send_json(Req1, 200, ticket_to_json(Ticket));
false ->
io:format("[TICKET_BY_ID] Access denied~n"),
send_error(Req1, 403, <<"Access denied">>)
end;
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Ticket not found">>); io:format("[TICKET_BY_ID] Ticket not found~n"),
{error, _} -> send_error(Req1, 404, <<"Ticket not found">>)
send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
io:format("[TICKET_BY_ID] Auth error: ~p - ~s~n", [Code, Message]),
send_error(Req1, Code, Message) send_error(Req1, Code, Message)
end. end.
%% PUT /v1/admin/tickets/:id - обновить тикет
update_ticket(Req) -> update_ticket(Req) ->
case handler_auth:authenticate(Req) of case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} -> {ok, UserId, Req1} ->
TicketId = cowboy_req:binding(id, Req1), TicketId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) -> Updates when is_map(Updates) ->
handle_ticket_action(AdminId, TicketId, Decoded, Req2); case core_ticket:get_by_id(TicketId) of
_ -> {ok, Ticket} ->
send_error(Req2, 400, <<"Invalid JSON">>) case is_admin(UserId) orelse Ticket#ticket.reporter_id =:= UserId of
true ->
case core_ticket:update_ticket(TicketId, Updates) of
{ok, Updated} -> send_json(Req2, 200, ticket_to_json(Updated));
{error, R} -> send_error(Req2, 500, R)
end;
false -> send_error(Req2, 403, <<"Access denied">>)
end;
{error, not_found} -> send_error(Req2, 404, <<"Ticket not found">>)
end;
_ -> send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) send_error(Req1, Code, Message)
end. end.
%% Обработка действий с тикетом is_admin(UserId) ->
handle_ticket_action(AdminId, TicketId, Body, Req) -> case core_user:get_by_id(UserId) of
case maps:get(<<"action">>, Body, undefined) of {ok, U} -> lists:member(U#user.role, [admin, superadmin, moderator, support]);
<<"status">> -> _ -> false
case maps:get(<<"status">>, Body, undefined) of
StatusBin when StatusBin =:= <<"open">>;
StatusBin =:= <<"in_progress">>;
StatusBin =:= <<"resolved">>;
StatusBin =:= <<"closed">> ->
Status = get_binary_to_atom(StatusBin),
case logic_ticket:update_status(AdminId, TicketId, Status) of
{ok, Ticket} ->
Response = ticket_to_json(Ticket),
send_json(Req, 200, Response);
{error, access_denied} ->
send_error(Req, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req, 404, <<"Ticket not found">>);
{error, _} ->
send_error(Req, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req, 400, <<"Invalid status">>)
end;
<<"assign">> ->
case maps:get(<<"admin_id">>, Body, undefined) of
undefined ->
send_error(Req, 400, <<"Missing admin_id field">>);
AssignToId ->
case logic_ticket:assign_ticket(AdminId, TicketId, AssignToId) of
{ok, Ticket} ->
Response = ticket_to_json(Ticket),
send_json(Req, 200, Response);
{error, access_denied} ->
send_error(Req, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req, 404, <<"Ticket not found">>);
{error, _} ->
send_error(Req, 500, <<"Internal server error">>)
end
end;
<<"resolve">> ->
Note = maps:get(<<"note">>, Body, <<"">>),
case logic_ticket:resolve_ticket(AdminId, TicketId, Note) of
{ok, Ticket} ->
Response = ticket_to_json(Ticket),
send_json(Req, 200, Response);
{error, access_denied} ->
send_error(Req, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req, 404, <<"Ticket not found">>);
{error, _} ->
send_error(Req, 500, <<"Internal server error">>)
end;
<<"close">> ->
case logic_ticket:close_ticket(AdminId, TicketId) of
{ok, Ticket} ->
Response = ticket_to_json(Ticket),
send_json(Req, 200, Response);
{error, access_denied} ->
send_error(Req, 403, <<"Admin access required">>);
{error, not_found} ->
send_error(Req, 404, <<"Ticket not found">>);
{error, _} ->
send_error(Req, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req, 400, <<"Invalid action">>)
end. end.
%% Вспомогательные функции ticket_to_json(T) ->
ticket_to_json(Ticket) ->
Context = try binary_to_term(Ticket#ticket.context) of
C -> C
catch
_:_ -> #{}
end,
#{ #{
id => Ticket#ticket.id, id => T#ticket.id,
error_hash => Ticket#ticket.error_hash, error_hash => T#ticket.error_hash,
error_message => Ticket#ticket.error_message, error_message => T#ticket.error_message,
stacktrace => Ticket#ticket.stacktrace, stacktrace => T#ticket.stacktrace,
context => Context, context => T#ticket.context,
count => Ticket#ticket.count, count => T#ticket.count,
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), first_seen => datetime_to_iso8601(T#ticket.first_seen),
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), last_seen => datetime_to_iso8601(T#ticket.last_seen),
status => Ticket#ticket.status, status => T#ticket.status,
assigned_to => Ticket#ticket.assigned_to, assigned_to => T#ticket.assigned_to,
resolution_note => Ticket#ticket.resolution_note resolution_note => T#ticket.resolution_note
}. }.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])). [Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
get_binary_to_atom(<<"open">>) -> open;
get_binary_to_atom(<<"in_progress">>) -> in_progress;
get_binary_to_atom(<<"resolved">>) -> resolved;
get_binary_to_atom(<<"closed">>) -> closed.
send_json(Req, Status, Data) -> send_json(Req, Status, Data) ->
Body = jsx:encode(Data), Body = jsx:encode(Data),

View File

@@ -1,113 +1,82 @@
-module(handler_tickets). -module(handler_tickets).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
init(Req, Opts) -> -include("records.hrl").
handle(Req, Opts).
init(Req0, Opts) ->
handle(Req0, Opts).
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req); <<"GET">> -> list_tickets(Req);
<<"POST">> -> report_error(Req); <<"POST">> -> create_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% POST /v1/tickets - сообщить об ошибке (доступно всем)
report_error(Req) ->
case handler_auth:authenticate(Req) of
{ok, _UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) ->
case Decoded of
#{<<"error_message">> := ErrorMessage} ->
Stacktrace = maps:get(<<"stacktrace">>, Decoded, <<"">>),
Context = maps:get(<<"context">>, Decoded, #{}),
case logic_ticket:report_error(ErrorMessage, Stacktrace, Context) of
{ok, Ticket} ->
Response = ticket_to_json(Ticket),
send_json(Req2, 201, Response);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing error_message field">>)
end;
_ ->
send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ ->
send_error(Req2, 400, <<"Invalid JSON format">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
%% GET /v1/admin/tickets - список тикетов (только админ)
list_tickets(Req) -> list_tickets(Req) ->
case handler_auth:authenticate(Req) of case handler_auth:authenticate(Req) of
{ok, AdminId, Req1} -> {ok, UserId, Req1} ->
Qs = cowboy_req:parse_qs(Req1), case is_admin(UserId) of
case proplists:get_value(<<"status">>, Qs) of true ->
undefined -> Tickets = core_ticket:list_all(),
case logic_ticket:list_tickets(AdminId) of send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]);
{ok, Tickets} -> false ->
Response = [ticket_to_json(T) || T <- Tickets], Tickets = core_ticket:list_by_user(UserId),
send_json(Req1, 200, Response); send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets])
{error, access_denied} ->
send_error(Req1, 403, <<"Admin access required">>);
{error, _} ->
send_error(Req1, 500, <<"Internal server error">>)
end;
StatusBin ->
Status = parse_status(StatusBin),
case logic_ticket:list_tickets_by_status(AdminId, Status) of
{ok, Tickets} ->
Response = [ticket_to_json(T) || T <- Tickets],
send_json(Req1, 200, Response);
{error, access_denied} ->
send_error(Req1, 403, <<"Admin access required">>);
{error, _} ->
send_error(Req1, 500, <<"Internal server error">>)
end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции create_ticket(Req) ->
parse_status(<<"open">>) -> open; case handler_auth:authenticate(Req) of
parse_status(<<"in_progress">>) -> in_progress; {ok, UserId, Req1} ->
parse_status(<<"resolved">>) -> resolved; {ok, Body, Req2} = cowboy_req:read_body(Req1),
parse_status(<<"closed">>) -> closed; try jsx:decode(Body, [return_maps]) of
parse_status(_) -> open. #{<<"error_message">> := _} = Data ->
TicketData = maps:merge(#{<<"reporter_id">> => UserId, <<"status">> => <<"open">>}, Data),
ticket_to_json(Ticket) -> case core_ticket:create_ticket(TicketData) of
Context = try binary_to_term(Ticket#ticket.context) of {ok, Ticket} ->
C -> C send_json(Req2, 201, ticket_to_json(Ticket));
{error, Reason} ->
send_error(Req2, 500, Reason)
end;
_ ->
send_error(Req2, 400, <<"Missing 'error_message' field">>)
catch catch
_:_ -> #{} _:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end, end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
is_admin(UserId) ->
case core_user:get_by_id(UserId) of
{ok, User} ->
lists:member(User#user.role, [admin, superadmin, moderator, support]);
_ -> false
end.
ticket_to_json(T) ->
#{ #{
id => Ticket#ticket.id, id => T#ticket.id,
error_hash => Ticket#ticket.error_hash, error_hash => T#ticket.error_hash,
error_message => Ticket#ticket.error_message, error_message => T#ticket.error_message,
stacktrace => Ticket#ticket.stacktrace, stacktrace => T#ticket.stacktrace,
context => Context, context => T#ticket.context,
count => Ticket#ticket.count, count => T#ticket.count,
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), first_seen => datetime_to_iso8601(T#ticket.first_seen),
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), last_seen => datetime_to_iso8601(T#ticket.last_seen),
status => Ticket#ticket.status, status => T#ticket.status,
assigned_to => Ticket#ticket.assigned_to, assigned_to => T#ticket.assigned_to,
resolution_note => Ticket#ticket.resolution_note resolution_note => T#ticket.resolution_note
}. }.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])). [Year, Month, Day, Hour, Minute, Second]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) -> send_json(Req, Status, Data) ->
Body = jsx:encode(Data), Body = jsx:encode(Data),

View File

@@ -1,10 +1,8 @@
-module(handler_user_me). -module(handler_user_me).
-include("records.hrl"). -include("records.hrl").
-export([init/2]). -export([init/2]).
init(Req, Opts) -> init(Req, Opts) -> handle(Req, Opts).
handle(Req, Opts).
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
@@ -36,8 +34,7 @@ authenticate(Req) ->
case cowboy_req:parse_header(<<"authorization">>, Req) of case cowboy_req:parse_header(<<"authorization">>, Req) of
{bearer, Token} -> {bearer, Token} ->
case logic_auth:verify_jwt(Token) of case logic_auth:verify_jwt(Token) of
{ok, Claims} -> {ok, UserId, _Role} -> % ← теперь возвращается {ok, UserId, Role}
UserId = maps:get(<<"user_id">>, Claims),
{ok, UserId, Req}; {ok, UserId, Req};
{error, expired} -> {error, expired} ->
{error, 401, <<"Token expired">>, Req}; {error, 401, <<"Token expired">>, Req};

View File

@@ -1,6 +1,5 @@
-module(ws_handler). -module(ws_handler).
-behaviour(cowboy_websocket). -behaviour(cowboy_websocket).
-export([init/2]). -export([init/2]).
-export([websocket_init/1]). -export([websocket_init/1]).
-export([websocket_handle/2]). -export([websocket_handle/2]).
@@ -13,15 +12,13 @@
}). }).
init(Req, _Opts) -> init(Req, _Opts) ->
% Аутентификация через query параметр token
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
case proplists:get_value(<<"token">>, Qs) of case proplists:get_value(<<"token">>, Qs) of
undefined -> undefined ->
{ok, cowboy_req:reply(401, #{}, <<"Missing token">>, Req), undefined}; {ok, cowboy_req:reply(401, #{}, <<"Missing token">>, Req), undefined};
Token -> Token ->
case logic_auth:verify_jwt(Token) of case logic_auth:verify_jwt(Token) of
{ok, Claims} -> {ok, UserId, _Role} ->
UserId = maps:get(<<"user_id">>, Claims),
{cowboy_websocket, Req, #state{user_id = UserId}}; {cowboy_websocket, Req, #state{user_id = UserId}};
{error, _} -> {error, _} ->
{ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined} {ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined}
@@ -29,7 +26,6 @@ init(Req, _Opts) ->
end. end.
websocket_init(State) -> websocket_init(State) ->
% Регистрируем процесс в pg для получения уведомлений
pg:join(eventhub_ws, self()), pg:join(eventhub_ws, self()),
{ok, State}. {ok, State}.
@@ -77,7 +73,6 @@ terminate(_Reason, _Req, _State) ->
pg:leave(eventhub_ws, self()), pg:leave(eventhub_ws, self()),
ok. ok.
%% Проверка, нужно ли отправлять уведомление пользователю
should_notify(calendar_update, #{calendar_id := CalId}, State) -> should_notify(calendar_update, #{calendar_id := CalId}, State) ->
lists:member(CalId, State#state.subscriptions); lists:member(CalId, State#state.subscriptions);
should_notify(booking_update, #{user_id := UserId}, State) -> should_notify(booking_update, #{user_id := UserId}, State) ->

View File

@@ -1,4 +1,4 @@
-module(auth). -module(eventhub_auth).
-export([ -export([
generate_user_token/2, generate_user_token/2,
generate_admin_token/2, generate_admin_token/2,
@@ -145,10 +145,13 @@ authenticate_admin_request(_Req, Email, Password) ->
%% ========== REFRESH TOKEN ========== %% ========== REFRESH TOKEN ==========
-spec generate_refresh_token(UserId :: binary()) -> {binary(), integer()}. -spec generate_refresh_token(UserId :: binary()) -> {binary(), calendar:datetime()}.
generate_refresh_token(_UserId) -> generate_refresh_token(_UserId) ->
RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), RefreshToken = base64:encode(crypto:strong_rand_bytes(32)),
ExpiresAt = erlang:system_time(second) + 2592000, % 30 дней Now = calendar:universal_time(),
ExpiresAt = calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(Now) + 30 * 24 * 3600
),
{RefreshToken, ExpiresAt}. {RefreshToken, ExpiresAt}.
%% ========== ВНУТРЕННИЕ ========== %% ========== ВНУТРЕННИЕ ==========

View File

@@ -1,88 +1,43 @@
-module(logic_auth). -module(logic_auth).
-export([hash_password/1, verify_password/2,
generate_jwt/2, verify_jwt/1,
generate_refresh_token/1,
authenticate_user/2]).
-export([hash_password/1, verify_password/2]). -include("records.hrl").
-export([generate_jwt/2, verify_jwt/1, extract_claims/1]).
-export([generate_refresh_token/1]).
%% ============ Argon2 хеширование ============ hash_password(Password) ->
hash_password(Password) when is_binary(Password) ->
argon2:hash(Password). argon2:hash(Password).
verify_password(Password, Hash) when is_binary(Password), is_binary(Hash) -> verify_password(Password, Hash) ->
argon2:verify(Password, Hash). argon2:verify(Password, Hash).
%% ============ JWT с использованием jose ============
get_jwt_secret() ->
<<"my-super-secret-key-for-jwt-32-bytes!">>.
get_jwk() ->
jose_jwk:from_oct(get_jwt_secret()).
generate_jwt(UserId, Role) -> generate_jwt(UserId, Role) ->
JWK = get_jwk(), eventhub_auth:generate_user_token(UserId, Role).
ExpTime = os:system_time(seconds) + 86400, % 24 часа verify_jwt(Token) ->
Claims = #{ eventhub_auth:verify_user_token(Token).
<<"user_id">> => UserId,
<<"role">> => Role,
<<"exp">> => ExpTime,
<<"iat">> => os:system_time(seconds)
},
JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), generate_refresh_token(UserId) ->
{_, Token} = jose_jws:compact(JWT), eventhub_auth:generate_refresh_token(UserId).
Token.
verify_jwt(Token) when is_binary(Token) -> authenticate_user(Email, Password) ->
try case core_user:get_by_email(Email) of
JWK = get_jwk(), {ok, User} ->
case jose_jwt:verify(JWK, Token) of case verify_password(Password, User#user.password_hash) of
{true, {jose_jwt, Claims}, _} -> {ok, true} ->
case check_expiry(Claims) of {ok, user_to_map(User)};
true -> {ok, Claims};
false -> {error, expired}
end;
{true, Claims, _} when is_map(Claims) ->
case check_expiry(Claims) of
true -> {ok, Claims};
false -> {error, expired}
end;
{false, _, _} ->
{error, invalid_signature}
end
catch
_:_ -> {error, invalid_token}
end.
extract_claims(Token) when is_binary(Token) ->
try
JWK = get_jwk(),
case jose_jwt:verify(JWK, Token) of
{true, {jose_jwt, Claims}, _} ->
{ok, Claims};
{true, Claims, _} when is_map(Claims) ->
{ok, Claims};
_ -> _ ->
{error, invalid_token} {error, invalid_credentials}
end end;
catch {error, not_found} ->
_:_ -> {error, invalid_token} {error, invalid_credentials}
end. end.
check_expiry(Claims) -> user_to_map(User) ->
case maps:find(<<"exp">>, Claims) of #{
{ok, Exp} when is_integer(Exp) -> id => User#user.id,
Exp > os:system_time(seconds); email => User#user.email,
_ -> role => atom_to_binary(User#user.role, utf8),
false status => atom_to_binary(User#user.status, utf8)
end. }.
%% ============ Refresh Token ============
generate_refresh_token(_UserId) ->
Token = base64:encode(crypto:strong_rand_bytes(32), #{mode => urlsafe, padding => false}),
ExpiresAt = calendar:universal_time_to_local_time(
calendar:gregorian_seconds_to_datetime(
calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 86400
)
),
{Token, ExpiresAt}.

View File

@@ -1,16 +1,27 @@
-module(logic_moderation). -module(logic_moderation).
-include("records.hrl"). -include("records.hrl").
-export([create_report/4, get_reports/1, get_reports_by_target/3, resolve_report/3]). -export([create_report/4,
-export([add_banned_word/2, remove_banned_word/2, list_banned_words/1]). get_reports/1,
-export([check_content/1, auto_moderate/1]). get_reports_by_target/3,
-export([freeze_calendar/2, unfreeze_calendar/2, freeze_event/2, unfreeze_event/2]). resolve_report/3]).
-define(REPORT_THRESHOLD, 3). % Количество жалоб для авто-заморозки -export([add_banned_word/2,
remove_banned_word/2,
list_banned_words/1]).
%% ============ Жалобы ============ -export([check_content/1,
auto_moderate/1]).
-export([freeze_calendar/2,
unfreeze_calendar/2,
freeze_event/2,
unfreeze_event/2]).
-define(REPORT_THRESHOLD, 3).
%% ============ Жалобы =====================================
%% Создание жалобы
create_report(ReporterId, TargetType, TargetId, Reason) -> create_report(ReporterId, TargetType, TargetId, Reason) ->
case target_exists(TargetType, TargetId) of case target_exists(TargetType, TargetId) of
true -> true ->
@@ -22,30 +33,29 @@ create_report(ReporterId, TargetType, TargetId, Reason) ->
target_id => TargetId, target_id => TargetId,
reason => Reason reason => Reason
}), }),
% Проверяем порог для авто-модерации
check_auto_freeze(TargetType, TargetId), check_auto_freeze(TargetType, TargetId),
{ok, Report}; {ok, Report};
Error -> Error Error ->
Error
end; end;
false -> {error, target_not_found} false ->
{error, target_not_found}
end. end.
%% Получить все жалобы (для админа)
get_reports(AdminId) -> get_reports(AdminId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_report:list_all(); true -> core_report:list_all();
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Получить жалобы на конкретную цель
get_reports_by_target(AdminId, TargetType, TargetId) -> get_reports_by_target(AdminId, TargetType, TargetId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_report:list_by_target(TargetType, TargetId); true -> core_report:list_by_target(TargetType, TargetId);
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Рассмотреть жалобу (подтвердить или отклонить) resolve_report(AdminId, ReportId, Action)
resolve_report(AdminId, ReportId, Action) when Action =:= reviewed; Action =:= dismissed -> when Action =:= reviewed; Action =:= dismissed ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
case core_report:get_by_id(ReportId) of case core_report:get_by_id(ReportId) of
@@ -53,19 +63,23 @@ resolve_report(AdminId, ReportId, Action) when Action =:= reviewed; Action =:= d
case Report#report.status of case Report#report.status of
pending -> pending ->
core_report:update_status(ReportId, Action, AdminId); core_report:update_status(ReportId, Action, AdminId);
_ -> {error, already_resolved} _ ->
{error, already_resolved}
end; end;
Error -> Error Error ->
Error
end; end;
false -> {error, access_denied} false ->
{error, access_denied}
end. end.
%% Проверка порога для авто-заморозки
check_auto_freeze(TargetType, TargetId) -> check_auto_freeze(TargetType, TargetId) ->
Count = core_report:get_count_by_target(TargetType, TargetId), Count = core_report:get_count_by_target(TargetType, TargetId),
if Count >= ?REPORT_THRESHOLD -> if
Count >= ?REPORT_THRESHOLD ->
auto_freeze(TargetType, TargetId); auto_freeze(TargetType, TargetId);
true -> ok true ->
ok
end. end.
auto_freeze(event, EventId) -> auto_freeze(event, EventId) ->
@@ -82,42 +96,52 @@ auto_freeze(calendar, CalendarId) ->
end; end;
auto_freeze(_, _) -> ok. auto_freeze(_, _) -> ok.
%% ============ Бан-лист ============ %% ============ Бан-лист ===================================
%% Добавить запрещённое слово
add_banned_word(AdminId, Word) -> add_banned_word(AdminId, Word) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_banned_word:add(Word); true -> core_banned_words:add_banned_word(Word, AdminId);
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Удалить запрещённое слово
remove_banned_word(AdminId, Word) -> remove_banned_word(AdminId, Word) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_banned_word:remove(Word); true -> core_banned_words:remove_banned_word(Word);
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Список запрещённых слов
list_banned_words(AdminId) -> list_banned_words(AdminId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_banned_word:list_all(); true -> {ok, core_banned_words:list_banned_words()};
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% ============ Контент-фильтр ============ %% ============ Контент-фильтр =============================
%% Проверить контент на запрещённые слова
check_content(Text) -> check_content(Text) ->
core_banned_word:check_text(Text). Words = core_banned_words:list_banned_words(),
LowerText = string:lowercase(binary_to_list(Text)),
lists:any(fun(W) ->
string:str(LowerText, binary_to_list(W#banned_word.word)) > 0
end, Words).
%% Автоматическая модерация контента (замена запрещённых слов)
auto_moderate(Text) -> auto_moderate(Text) ->
core_banned_word:filter_text(Text). Words = core_banned_words:list_banned_words(),
lists:foldl(fun(W, Acc) ->
WordStr = binary_to_list(W#banned_word.word),
LowerAccStr = string:lowercase(binary_to_list(Acc)),
case string:str(LowerAccStr, WordStr) of
0 -> Acc;
Pos ->
Len = length(WordStr),
Start = binary:part(Acc, {0, Pos-1}),
Rest = binary:part(Acc, {Pos-1+Len, byte_size(Acc)-Pos+1-Len}),
<<Start/binary, "***", Rest/binary>>
end
end, Text, Words).
%% ============ Заморозка/разморозка ============ %% ============ Заморозка/разморозка =======================
%% Заморозить календарь
freeze_calendar(AdminId, CalendarId) -> freeze_calendar(AdminId, CalendarId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
@@ -129,7 +153,6 @@ freeze_calendar(AdminId, CalendarId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Разморозить календарь
unfreeze_calendar(AdminId, CalendarId) -> unfreeze_calendar(AdminId, CalendarId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
@@ -141,7 +164,6 @@ unfreeze_calendar(AdminId, CalendarId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Заморозить событие
freeze_event(AdminId, EventId) -> freeze_event(AdminId, EventId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
@@ -153,7 +175,6 @@ freeze_event(AdminId, EventId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Разморозить событие
unfreeze_event(AdminId, EventId) -> unfreeze_event(AdminId, EventId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
@@ -165,7 +186,7 @@ unfreeze_event(AdminId, EventId) ->
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% ============ Вспомогательные функции ============ %% ============ Вспомогательные функции ====================
target_exists(event, EventId) -> target_exists(event, EventId) ->
case core_event:get_by_id(EventId) of case core_event:get_by_id(EventId) of
@@ -176,7 +197,7 @@ target_exists(calendar, CalendarId) ->
case core_calendar:get_by_id(CalendarId) of case core_calendar:get_by_id(CalendarId) of
{ok, _} -> true; {ok, _} -> true;
_ -> false _ -> false
end; % ← точка с запятой здесь! end;
target_exists(_, _) -> false. target_exists(_, _) -> false.
is_admin(UserId) -> is_admin(UserId) ->

View File

@@ -1,21 +1,41 @@
-module(logic_ticket). -module(logic_ticket).
-include("records.hrl"). -include("records.hrl").
-export([report_error/3, get_ticket/2, list_tickets/1, list_tickets_by_status/2]). -export([report_error/3,
-export([update_status/3, assign_ticket/3, resolve_ticket/3, close_ticket/2]). get_ticket/2,
-export([get_statistics/1]). list_tickets/1,
list_tickets_by_status/2,
update_status/3,
assign_ticket/3,
resolve_ticket/3,
close_ticket/2,
get_statistics/1]).
%% Зарегистрировать ошибку (создать или обновить тикет) %% Зарегистрировать ошибку (создать или обновить тикет)
report_error(ErrorMessage, Stacktrace, Context) -> report_error(ErrorMessage, Stacktrace, Context) ->
case core_ticket:create_or_update(ErrorMessage, Stacktrace, Context) of Existing = [T || T <- core_ticket:list_all(), T#ticket.error_message =:= ErrorMessage],
{ok, Ticket} -> case Existing of
% Если это новый тикет, уведомляем администраторов (заглушка) [Ticket] ->
case Ticket#ticket.count of % Увеличить счётчик и обновить last_seen
1 -> notify_admins(Ticket); Updated = Ticket#ticket{
_ -> ok count = Ticket#ticket.count + 1,
end, last_seen = calendar:universal_time()
{ok, Ticket}; },
mnesia:dirty_write(Updated),
{ok, Updated};
[] ->
Data = #{
<<"error_message">> => ErrorMessage,
<<"stacktrace">> => Stacktrace,
<<"context">> => list_to_binary(io_lib:format("~p", [Context]))
},
case core_ticket:create_ticket(Data) of
{ok, Ticket} = Result ->
% Уведомление администраторов (заглушка)
notify_admins(Ticket),
Result;
Error -> Error Error -> Error
end
end. end.
%% Получить тикет (только для админов) %% Получить тикет (только для админов)
@@ -35,21 +55,23 @@ list_tickets(AdminId) ->
%% Список тикетов по статусу (только для админов) %% Список тикетов по статусу (только для админов)
list_tickets_by_status(AdminId, Status) -> list_tickets_by_status(AdminId, Status) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_ticket:list_by_status(Status); true ->
All = core_ticket:list_all(),
[T || T <- All, T#ticket.status =:= Status];
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Обновить статус тикета %% Обновить статус тикета
update_status(AdminId, TicketId, Status) -> update_status(AdminId, TicketId, Status) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_ticket:update_status(TicketId, Status); true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status});
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Назначить тикет администратору %% Назначить тикет администратору
assign_ticket(AdminId, TicketId, AssignToId) -> assign_ticket(AdminId, TicketId, AssignToId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_ticket:assign(TicketId, AssignToId); true -> core_ticket:update_ticket(TicketId, #{<<"assigned_to">> => AssignToId});
false -> {error, access_denied} false -> {error, access_denied}
end. end.
@@ -57,18 +79,17 @@ assign_ticket(AdminId, TicketId, AssignToId) ->
resolve_ticket(AdminId, TicketId, ResolutionNote) -> resolve_ticket(AdminId, TicketId, ResolutionNote) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
case core_ticket:add_resolution(TicketId, ResolutionNote) of core_ticket:update_ticket(TicketId, #{
{ok, _Ticket} -> <<"status">> => <<"closed">>,
core_ticket:update_status(TicketId, resolved); <<"resolution_note">> => ResolutionNote
Error -> Error });
end;
false -> {error, access_denied} false -> {error, access_denied}
end. end.
%% Закрыть тикет %% Закрыть тикет
close_ticket(AdminId, TicketId) -> close_ticket(AdminId, TicketId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> core_ticket:update_status(TicketId, closed); true -> core_ticket:update_ticket(TicketId, #{<<"status">> => <<"closed">>});
false -> {error, access_denied} false -> {error, access_denied}
end. end.
@@ -76,14 +97,14 @@ close_ticket(AdminId, TicketId) ->
get_statistics(AdminId) -> get_statistics(AdminId) ->
case is_admin(AdminId) of case is_admin(AdminId) of
true -> true ->
{ok, AllTickets} = core_ticket:list_all(), All = core_ticket:list_all(),
Open = length([T || T <- AllTickets, T#ticket.status =:= open]), Open = length([T || T <- All, T#ticket.status =:= open]),
InProgress = length([T || T <- AllTickets, T#ticket.status =:= in_progress]), InProgress = length([T || T <- All, T#ticket.status =:= in_progress]),
Resolved = length([T || T <- AllTickets, T#ticket.status =:= resolved]), Resolved = length([T || T <- All, T#ticket.status =:= resolved]),
Closed = length([T || T <- AllTickets, T#ticket.status =:= closed]), Closed = length([T || T <- All, T#ticket.status =:= closed]),
TotalErrors = lists:sum([T#ticket.count || T <- AllTickets]), TotalErrors = lists:sum([T#ticket.count || T <- All]),
#{ #{
total_tickets => length(AllTickets), total_tickets => length(All),
open => Open, open => Open,
in_progress => InProgress, in_progress => InProgress,
resolved => Resolved, resolved => Resolved,
@@ -102,6 +123,4 @@ is_admin(UserId) ->
end. end.
notify_admins(_Ticket) -> notify_admins(_Ticket) ->
% Заглушка для уведомлений администраторов
% В будущем здесь будет отправка email/websocket
ok. ok.

View File

@@ -3,24 +3,154 @@
test() -> test() ->
io:format("Testing admin panel API...~n"), io:format("Testing admin panel API...~n"),
AdminURL = "http://localhost:8445",
% Получаем admin-токен через test runner (уже проверенный)
AdminToken = api_test_runner:get_admin_token(), AdminToken = api_test_runner:get_admin_token(),
% TEST 1: Admin healthcheck %% TEST 1: Admin healthcheck (public)
io:format(" TEST 1: Admin healthcheck... "), io:format(" TEST 1: Admin healthcheck... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {"http://localhost:8445/admin/health", []}, [], []), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/health", []}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 2: Admin stats %% TEST 2: Admin login (дополнительная проверка)
io:format(" TEST 2: Admin stats... "), io:format(" TEST 2: Admin login (attempt)... "),
LoginBody = jsx:encode(#{<<"email">> => <<"global_admin@test.com">>, <<"password">> => <<"admin123">>}),
case httpc:request(post, {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []) of
{ok, {{_, 200, _}, _, _}} ->
io:format("OK (logged in)~n");
_ ->
io:format("SKIPPED (credentials not found, using runner token)~n")
end,
%% TEST 3: Admin stats
io:format(" TEST 3: Admin stats... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {ok, {{_, 200, _}, _, _}} = httpc:request(get,
{"http://localhost:8445/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 3: List users %% TEST 4: List users
io:format(" TEST 3: List users... "), io:format(" TEST 4: List users... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get, {ok, {{_, 200, _}, _, _}} = httpc:request(get,
{"http://localhost:8445/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 5: Get user by ID
io:format(" TEST 5: Get user by ID... "),
UserId = api_test_runner:get_user_id(),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 6: List reports
io:format(" TEST 6: List reports... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 7: List banned words
io:format(" TEST 7: List banned words... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 8: Add banned word
io:format(" TEST 8: Add banned word... "),
BannedWordBody = jsx:encode(#{<<"word">> => <<"test_banned_word">>}),
{ok, {{_, 201, _}, _, _}} = httpc:request(post,
{AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []),
io:format("OK~n"),
%% TEST 9: Delete banned word
io:format(" TEST 9: Delete banned word... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 10: List tickets
io:format(" TEST 10: List tickets... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 11: Create ticket
io:format(" TEST 11: Create ticket... "),
TicketBody = jsx:encode(#{<<"error_message">> => <<"Test error">>, <<"stacktrace">> => <<"trace">>}),
{ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post,
{AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []),
#{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]),
io:format("OK~n"),
%% TEST 12: Get ticket by ID
io:format(" TEST 12: Get ticket by ID... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 13: Update ticket
io:format(" TEST 13: Update ticket... "),
UpdateTicketBody = jsx:encode(#{<<"status">> => <<"closed">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []),
io:format("OK~n"),
%% TEST 14: Delete ticket
io:format(" TEST 14: Delete ticket... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 15: Ticket stats
io:format(" TEST 15: Ticket stats... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 16: List subscriptions
io:format(" TEST 16: List subscriptions... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 17: Create subscription
io:format(" TEST 17: Create subscription... "),
SubBody = jsx:encode(#{<<"user_id">> => UserId, <<"plan">> => <<"monthly">>}),
{ok, {{_, 201, _}, _, SubResp}} = httpc:request(post,
{AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []),
#{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]),
io:format("OK~n"),
%% TEST 18: Get subscription by ID
io:format(" TEST 18: Get subscription by ID... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 19: Update subscription
io:format(" TEST 19: Update subscription... "),
UpdateSubBody = jsx:encode(#{<<"status">> => <<"cancelled">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []),
io:format("OK~n"),
%% TEST 20: Delete subscription
io:format(" TEST 20: Delete subscription... "),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"),
%% TEST 21: Moderation - block user
io:format(" TEST 21: Moderation - block user... "),
ModBody = jsx:encode(#{<<"action">> => <<"block">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []),
io:format("OK~n"),
%% TEST 22: Moderation - unblock user
io:format(" TEST 22: Moderation - unblock user... "),
UnblockBody = jsx:encode(#{<<"action">> => <<"unblock">>}),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []),
io:format("OK~n"), io:format("OK~n"),
io:format("~n✅ Admin API tests passed!~n"), io:format("~n✅ Admin API tests passed!~n"),

View File

@@ -2,52 +2,70 @@
-export([test/0]). -export([test/0]).
-define(BASE_URL, "http://localhost:8080"). -define(BASE_URL, "http://localhost:8080").
-define(ADMIN_BASE_URL, "http://localhost:8445").
test() -> test() ->
io:format("Testing moderation API...~n"), io:format("Testing moderation API...~n"),
AdminToken = api_test_runner:get_admin_token(), AdminToken = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(), UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие %% Создаём календарь и событие через пользовательский API
CalId = api_test_runner:extract_json( CalId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars", #{title => <<"Mod Cal">>}, UserToken), <<"id">>), api_test_runner:http_post("/v1/calendars", #{title => <<"Mod Cal">>}, UserToken),
<<"id">>),
EventId = api_test_runner:extract_json( EventId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
#{title => <<"Mod Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, UserToken), <<"id">>), #{title => <<"Mod Event">>,
start_time => <<"2026-06-01T10:00:00Z">>,
duration => 60},
UserToken),
<<"id">>),
% TEST 1: Create report %% TEST 1: Create report (пользователь)
io:format(" TEST 1: Create report... "), io:format(" TEST 1: Create report... "),
ReportId = api_test_runner:extract_json( ReportId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/reports", api_test_runner:http_post("/v1/reports",
#{target_type => <<"event">>, target_id => EventId, reason => <<"Inappropriate">>}, UserToken), <<"id">>), #{target_type => <<"event">>,
target_id => EventId,
reason => <<"Inappropriate">>},
UserToken),
<<"id">>),
io:format("OK~n"), io:format("OK~n"),
% TEST 2: Admin views reports %% TEST 2: Admin views reports (через админский URL, прямой httpc)
io:format(" TEST 2: Admin views reports... "), io:format(" TEST 2: Admin views reports... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/reports", AdminToken), {ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 3: Admin resolves report %% TEST 3: Admin resolves report
io:format(" TEST 3: Admin resolves report... "), io:format(" TEST 3: Admin resolves report... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/reports/" ++ binary_to_list(ReportId), {ok, {{_, 200, _}, _, _}} = httpc:request(put,
#{action => <<"review">>}, AdminToken), {?ADMIN_BASE_URL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{status => <<"reviewed">>})}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 4: Add banned word %% TEST 4: Add banned word (админ)
io:format(" TEST 4: Add banned word... "), io:format(" TEST 4: Add banned word... "),
{ok, {{_, 201, _}, _, _}} = api_test_runner:http_post("/v1/admin/banned-words", {ok, {{_, 201, _}, _, _}} = httpc:request(post,
#{word => <<"badword">>}, AdminToken), {?ADMIN_BASE_URL ++ "/v1/admin/banned-words",
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{<<"word">> => <<"badword">>})}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 5: List banned words %% TEST 5: List banned words (админ)
io:format(" TEST 5: List banned words... "), io:format(" TEST 5: List banned words... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/banned-words", AdminToken), {ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"), io:format("OK~n"),
% TEST 6: Remove banned word %% TEST 6: Remove banned word (админ)
io:format(" TEST 6: Remove banned word... "), io:format(" TEST 6: Remove banned word... "),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/admin/banned-words/badword", AdminToken), {ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{?ADMIN_BASE_URL ++ "/v1/admin/banned-words/badword", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format("OK~n"), io:format("OK~n"),
io:format("~n✅ Moderation API tests passed!~n"), io:format("~n✅ Moderation API tests passed!~n"),

View File

@@ -1,37 +1,78 @@
-module(api_tickets_tests). -module(api_tickets_tests).
-export([test/0]). -export([test/0]).
-define(BASE_URL, "http://localhost:8080"). -define(ADMIN_BASE_URL, "http://localhost:8445").
test() -> test() ->
io:format("Testing tickets API...~n"), io:format("Testing tickets API...~n"),
Token = api_test_runner:get_user_token(),
AdminToken = api_test_runner:get_admin_token(), AdminToken = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% TEST 1: Report error %% TEST 1: Create ticket (user)
io:format(" TEST 1: Report error... "), io:format(" TEST 1: Create ticket...~n"),
io:format(" POST /v1/tickets~n"),
TicketId = api_test_runner:extract_json( TicketId = api_test_runner:extract_json(
api_test_runner:http_post("/v1/tickets", api_test_runner:http_post("/v1/tickets",
#{error_message => <<"Test bug">>, stacktrace => <<"line 1">>}, UserToken), <<"id">>), #{error_message => <<"Bug">>,
io:format("OK~n"), stacktrace => <<"Something broke">>},
Token),
<<"id">>),
io:format(" OK~n"),
% TEST 2: Admin views tickets %% TEST 2: Get my tickets (user)
io:format(" TEST 2: Admin views tickets... "), io:format(" TEST 2: Get my tickets...~n"),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/tickets", AdminToken), io:format(" GET /v1/tickets~n"),
io:format("OK~n"), {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/tickets", Token),
io:format(" OK~n"),
% TEST 3: Update ticket status %% TEST 3: Get single ticket (user)
io:format(" TEST 3: Update ticket status... "), io:format(" TEST 3: Get single ticket...~n"),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/tickets/" ++ binary_to_list(TicketId), io:format(" GET /v1/tickets/~s~n", [TicketId]),
#{action => <<"status">>, status => <<"in_progress">>}, AdminToken), {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get(
io:format("OK~n"), "/v1/tickets/" ++ binary_to_list(TicketId),
Token),
io:format(" OK~n"),
% TEST 4: Close ticket %% TEST 4: Admin lists all tickets
io:format(" TEST 4: Close ticket... "), io:format(" TEST 4: Admin lists all tickets...~n"),
{ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/tickets/" ++ binary_to_list(TicketId), io:format(" GET ~s/v1/admin/tickets~n", [?ADMIN_BASE_URL]),
#{action => <<"close">>}, AdminToken), {ok, {{_, 200, _}, _, _}} = httpc:request(get,
io:format("OK~n"), {?ADMIN_BASE_URL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format(" OK~n"),
%% TEST 5: Admin updates ticket status
io:format(" TEST 5: Admin updates ticket status...~n"),
io:format(" PUT ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{status => <<"in_progress">>})}, [], []),
io:format(" OK~n"),
%% TEST 6: Admin assigns ticket
io:format(" TEST 6: Admin assigns ticket...~n"),
io:format(" PUT ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]),
{ok, {{_, 200, _}, _, _}} = httpc:request(put,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId),
[{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}],
"application/json",
jsx:encode(#{assigned_to => AdminToken})}, [], []),
io:format(" OK~n"),
%% TEST 7: Admin views ticket stats
io:format(" TEST 7: Admin views ticket stats...~n"),
io:format(" GET ~s/v1/admin/tickets/stats~n", [?ADMIN_BASE_URL]),
{ok, {{_, 200, _}, _, _}} = httpc:request(get,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format(" OK~n"),
%% TEST 8: Admin deletes ticket
io:format(" TEST 8: Admin deletes ticket...~n"),
io:format(" DELETE ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]),
{ok, {{_, 200, _}, _, _}} = httpc:request(delete,
{?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []),
io:format(" OK~n"),
io:format("~n✅ Tickets API tests passed!~n"), io:format("~n✅ Tickets API tests passed!~n"),
{?MODULE, ok}. {?MODULE, ok}.

View File

@@ -46,7 +46,7 @@ test_get_report() ->
target_type = <<"event">>, target_type = <<"event">>,
target_id = <<"e1">>, target_id = <<"e1">>,
reason = <<"spam">>, reason = <<"spam">>,
status = <<"new">>, status = pending,
created_at = {{2026,4,26},{12,0,0}}, created_at = {{2026,4,26},{12,0,0}},
resolved_at = undefined resolved_at = undefined
}, },
@@ -55,7 +55,7 @@ test_get_report() ->
{ok, _, _} = admin_handler_report_by_id:init(req, []), {ok, _, _} = admin_handler_report_by_id:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
#{<<"id">> := <<"r1">>, <<"status">> := <<"new">>} = jsx:decode(RespBody, [return_maps]). #{<<"id">> := <<"r1">>, <<"status">> := <<"pending">>} = jsx:decode(RespBody, [return_maps]).
%% GET не найдено %% GET не найдено
test_get_report_not_found() -> test_get_report_not_found() ->
@@ -94,9 +94,9 @@ test_update_report() ->
fun(id, _) -> <<"r1">> end), fun(id, _) -> <<"r1">> end),
ok = meck:expect(cowboy_req, read_body, ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end),
Updated = #report{id = <<"r1">>, status = <<"reviewed">>}, Updated = #report{id = <<"r1">>, status = reviewed},
ok = meck:expect(core_report, update_status, ok = meck:expect(core_report, update_status,
fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), fun(<<"r1">>, reviewed, <<"adm1">>) -> {ok, Updated} end),
{ok, _, _} = admin_handler_report_by_id:init(req, []), {ok, _, _} = admin_handler_report_by_id:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
@@ -115,7 +115,7 @@ test_update_report_not_found() ->
ok = meck:expect(cowboy_req, read_body, ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end),
ok = meck:expect(core_report, update_status, ok = meck:expect(core_report, update_status,
fun(_, _) -> {error, not_found} end), fun(<<"r99">>, reviewed, <<"adm1">>) -> {error, not_found} end),
{ok, _, _} = admin_handler_report_by_id:init(req, []), {ok, _, _} = admin_handler_report_by_id:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(404, Status). ?assertEqual(404, Status).

View File

@@ -29,6 +29,7 @@ admin_reports_test_() ->
{"POST /admin/reports method not allowed", fun test_wrong_method/0} {"POST /admin/reports method not allowed", fun test_wrong_method/0}
]}. ]}.
%% GET успех
test_list_reports() -> test_list_reports() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, ok = meck:expect(handler_auth, authenticate,
@@ -42,17 +43,18 @@ test_list_reports() ->
target_type = <<"event">>, target_type = <<"event">>,
target_id = <<"e1">>, target_id = <<"e1">>,
reason = <<"spam">>, reason = <<"spam">>,
status = <<"new">>, status = pending,
created_at = {{2026,4,26},{12,0,0}}, created_at = {{2026,4,26},{12,0,0}},
resolved_at = undefined resolved_at = undefined
}, },
ok = meck:expect(core_report, list_reports, fun() -> [Report] end), % list_all возвращает {ok, List}
ok = meck:expect(core_report, list_all, fun() -> {ok, [Report]} end),
{ok, _, _} = admin_handler_reports:init(req, []), {ok, _, _} = admin_handler_reports:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
[#{<<"id">> := <<"r1">>, <<"target_type">> := <<"event">>, <<"status">> := <<"new">>}] [#{<<"id">> := <<"r1">>, <<"status">> := <<"pending">>}] = jsx:decode(RespBody, [return_maps]).
= jsx:decode(RespBody, [return_maps]).
%% GET запрещён
test_list_reports_forbidden() -> test_list_reports_forbidden() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, ok = meck:expect(handler_auth, authenticate,
@@ -62,6 +64,7 @@ test_list_reports_forbidden() ->
?assertEqual(403, Status), ?assertEqual(403, Status),
#{<<"error">> := <<"Admin access required">>} = jsx:decode(RespBody, [return_maps]). #{<<"error">> := <<"Admin access required">>} = jsx:decode(RespBody, [return_maps]).
%% PUT успех
test_update_report() -> test_update_report() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end),
ok = meck:expect(handler_auth, authenticate, ok = meck:expect(handler_auth, authenticate,
@@ -73,14 +76,16 @@ test_update_report() ->
fun(id, _) -> <<"r1">> end), fun(id, _) -> <<"r1">> end),
ok = meck:expect(cowboy_req, read_body, ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end),
Updated = #report{id = <<"r1">>, status = <<"reviewed">>}, Updated = #report{id = <<"r1">>, status = reviewed},
% обработчик передаёт бинарный статус, поэтому мок ожидает строку
ok = meck:expect(core_report, update_status, ok = meck:expect(core_report, update_status,
fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), fun(<<"r1">>, <<"reviewed">>, <<"adm1">>) -> {ok, Updated} end),
{ok, _, _} = admin_handler_reports:init(req, []), {ok, _, _} = admin_handler_reports:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
#{<<"status">> := <<"reviewed">>} = jsx:decode(RespBody, [return_maps]). #{<<"status">> := <<"reviewed">>} = jsx:decode(RespBody, [return_maps]).
%% PUT невалидный JSON
test_update_report_bad_json() -> test_update_report_bad_json() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end),
ok = meck:expect(handler_auth, authenticate, ok = meck:expect(handler_auth, authenticate,
@@ -93,9 +98,10 @@ test_update_report_bad_json() ->
ok = meck:expect(cowboy_req, read_body, ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, <<"bad json">>, Req} end), fun(Req) -> {ok, <<"bad json">>, Req} end),
{ok, _, _} = admin_handler_reports:init(req, []), {ok, _, _} = admin_handler_reports:init(req, []),
{Status, _, _, _} = erase(test_reply), %% исправлено: четыре элемента {Status, _, _, _} = erase(test_reply),
?assertEqual(400, Status). ?assertEqual(400, Status).
%% PUT не найдено
test_update_report_not_found() -> test_update_report_not_found() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end),
ok = meck:expect(handler_auth, authenticate, ok = meck:expect(handler_auth, authenticate,
@@ -108,11 +114,12 @@ test_update_report_not_found() ->
ok = meck:expect(cowboy_req, read_body, ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end),
ok = meck:expect(core_report, update_status, ok = meck:expect(core_report, update_status,
fun(_, _) -> {error, not_found} end), fun(<<"r99">>, <<"reviewed">>, <<"adm1">>) -> {error, not_found} end),
{ok, _, _} = admin_handler_reports:init(req, []), {ok, _, _} = admin_handler_reports:init(req, []),
{Status, _, _, _} = erase(test_reply), %% исправлено: четыре элемента {Status, _, _, _} = erase(test_reply),
?assertEqual(404, Status). ?assertEqual(404, Status).
%% Неправильный метод
test_wrong_method() -> test_wrong_method() ->
ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end),
{ok, _, _} = admin_handler_reports:init(req, []), {ok, _, _} = admin_handler_reports:init(req, []),

View File

@@ -34,169 +34,197 @@ admin_tickets_test_() ->
{"PATCH /admin/tickets method not allowed", fun test_wrong_method/0} {"PATCH /admin/tickets method not allowed", fun test_wrong_method/0}
]}. ]}.
%% GET список тикетов (успех)
test_list() -> test_list() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> undefined end), % для маршрута без id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
fun(<<"adm1">>) -> {ok, AdminUser} end),
Ticket = #ticket{ Ticket = #ticket{
id = <<"t1">>, id = <<"t1">>,
error_hash = <<"abc123">>, error_hash = <<"hash1">>,
error_message = <<"Ooops">>, error_message = <<"Error message">>,
stacktrace = <<"trace">>, stacktrace = <<"stack">>,
context = <<"ctx">>, context = <<"ctx">>,
count = 3, count = 1,
first_seen = {{2026,4,27},{12,0,0}}, first_seen = {{2026,4,28},{12,0,0}},
last_seen = {{2026,4,27},{13,0,0}}, last_seen = {{2026,4,28},{12,0,0}},
status = open, status = open,
assigned_to = <<"adm2">>, assigned_to = undefined,
resolution_note = undefined resolution_note = undefined
}, },
ok = meck:expect(core_ticket, list_tickets, fun() -> [Ticket] end), ok = meck:expect(core_ticket, list_all, fun() -> [Ticket] end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
[#{<<"id">> := <<"t1">>, <<"error_message">> := <<"Ooops">>, <<"status">> := <<"open">>}] = [#{<<"id">> := <<"t1">>, <<"error_message">> := <<"Error message">>}] =
jsx:decode(RespBody, [return_maps]). jsx:decode(RespBody, [return_maps]).
%% GET запрещён
test_list_forbidden() -> test_list_forbidden() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {error, 403, <<"Admin access required">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> undefined end), % для маршрута без id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {error, 403, <<"Admin access required">>, Req} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(403, Status). ?assertEqual(403, Status).
%% POST создание тикета
test_create() -> test_create() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> undefined end), % для маршрута без id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
BodyMap = #{<<"error_message">> => <<"New bug">>, <<"stacktrace">> => <<"trace">>}, fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(BodyMap), Req} end), BodyMap = #{<<"error_message">> => <<"Bug">>, <<"stacktrace">> => <<"trace">>},
Created = #ticket{ ok = meck:expect(cowboy_req, read_body,
id = <<"t_new">>, fun(Req) -> {ok, jsx:encode(BodyMap), Req} end),
error_hash = <<"hash">>, Created = #ticket{id = <<"t_new">>, error_message = <<"Bug">>, status = open},
error_message = <<"New bug">>, ok = meck:expect(core_ticket, create_ticket, fun(_) -> {ok, Created} end),
stacktrace = <<"trace">>,
context = <<>>,
count = 1,
first_seen = {{2026,4,27},{14,0,0}},
last_seen = {{2026,4,27},{14,0,0}},
status = open,
assigned_to = undefined,
resolution_note = undefined
},
ok = meck:expect(core_ticket, create_ticket, fun(Data) ->
true = maps:is_key(<<"error_message">>, Data),
{ok, Created}
end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(201, Status), ?assertEqual(201, Status),
#{<<"error_message">> := <<"New bug">>, <<"status">> := <<"open">>} = jsx:decode(RespBody, [return_maps]). #{<<"error_message">> := <<"Bug">>} = jsx:decode(RespBody, [return_maps]).
%% POST отсутствует поле error_message
test_create_missing() -> test_create_missing() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> undefined end), % для маршрута без id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"desc">> => <<"no msg">>}), Req} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"title">> => <<"No msg">>}), Req} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(400, Status). ?assertEqual(400, Status).
%% GET один тикет по ID
test_get() -> test_get() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t1">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
Ticket = #ticket{ fun(<<"adm1">>) -> {ok, AdminUser} end),
id = <<"t1">>, Ticket = #ticket{id = <<"t1">>, error_message = <<"Test">>, status = open},
error_hash = <<"abc">>, ok = meck:expect(core_ticket, get_by_id,
error_message = <<"msg">>, fun(<<"t1">>) -> {ok, Ticket} end),
stacktrace = <<>>,
context = <<>>,
count = 1,
first_seen = {{2026,4,27},{12,0,0}},
last_seen = {{2026,4,27},{12,0,0}},
status = open,
assigned_to = undefined,
resolution_note = undefined
},
ok = meck:expect(core_ticket, get_by_id, fun(<<"t1">>) -> {ok, Ticket} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
#{<<"id">> := <<"t1">>} = jsx:decode(RespBody, [return_maps]). #{<<"id">> := <<"t1">>} = jsx:decode(RespBody, [return_maps]).
%% GET тикет не найден
test_get_not_found() -> test_get_not_found() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t99">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(core_ticket, get_by_id, fun(_) -> {error, not_found} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(core_ticket, get_by_id,
fun(_) -> {error, not_found} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(404, Status). ?assertEqual(404, Status).
%% PUT обновление тикета
test_update() -> test_update() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t1">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end),
Updated = #ticket{id = <<"t1">>, status = closed}, Updated = #ticket{id = <<"t1">>, status = closed},
ok = meck:expect(core_ticket, update_ticket, fun(<<"t1">>, _) -> {ok, Updated} end), ok = meck:expect(core_ticket, update_ticket,
fun(<<"t1">>, _) -> {ok, Updated} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
#{<<"status">> := <<"closed">>} = jsx:decode(RespBody, [return_maps]). #{<<"status">> := <<"closed">>} = jsx:decode(RespBody, [return_maps]).
%% PUT тикет не найден
test_update_not_found() -> test_update_not_found() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t99">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(core_ticket, update_ticket, fun(_, _) -> {error, not_found} end), ok = meck:expect(cowboy_req, read_body,
fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end),
ok = meck:expect(core_ticket, update_ticket,
fun(_, _) -> {error, not_found} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(404, Status). ?assertEqual(404, Status).
%% DELETE удаление тикета
test_delete() -> test_delete() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t1">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(core_ticket, delete_ticket, fun(<<"t1">>) -> {ok, deleted} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(core_ticket, delete_ticket,
fun(<<"t1">>) -> {ok, deleted} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(200, Status), ?assertEqual(200, Status),
#{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]).
%% DELETE тикет не найден
test_delete_not_found() -> test_delete_not_found() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end),
ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), ok = meck:expect(cowboy_req, binding,
fun(id, _) -> <<"t99">> end), % для маршрута с id
ok = meck:expect(handler_auth, authenticate,
fun(Req) -> {ok, <<"adm1">>, Req} end),
AdminUser = #user{id = <<"adm1">>, role = admin}, AdminUser = #user{id = <<"adm1">>, role = admin},
ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), ok = meck:expect(core_user, get_by_id,
ok = meck:expect(core_ticket, delete_ticket, fun(_) -> {error, not_found} end), fun(<<"adm1">>) -> {ok, AdminUser} end),
ok = meck:expect(core_ticket, delete_ticket,
fun(_) -> {error, not_found} end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, _, _} = erase(test_reply), {Status, _, _, _} = erase(test_reply),
?assertEqual(404, Status). ?assertEqual(404, Status).
%% Неправильный метод
test_wrong_method() -> test_wrong_method() ->
ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end),
ok = meck:expect(cowboy_req, method, fun(_) -> <<"PATCH">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PATCH">> end),
ok = meck:expect(cowboy_req, binding,
fun(id, _) -> undefined end), % для маршрута без id
ok = meck:expect(cowboy_req, reply,
fun(Code, Headers, Body, Req) ->
put(test_reply, {Code, Headers, Body, Req})
end),
{ok, _, _} = admin_handler_tickets:init(req, []), {ok, _, _} = admin_handler_tickets:init(req, []),
{Status, _, RespBody, _} = erase(test_reply), {Status, _, RespBody, _} = erase(test_reply),
?assertEqual(405, Status), ?assertEqual(405, Status),

View File

@@ -27,21 +27,21 @@ generate_user_token_test_() ->
{setup, fun setup/0, fun cleanup/1, [ {setup, fun setup/0, fun cleanup/1, [
{"Generate user token returns a binary", {"Generate user token returns a binary",
fun() -> fun() ->
Token = auth:generate_user_token(<<"user123">>, <<"user">>), Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>),
?assert(is_binary(Token)), ?assert(is_binary(Token)),
?assert(size(Token) > 0) ?assert(size(Token) > 0)
end}, end},
{"Generated user token can be verified", {"Generated user token can be verified",
fun() -> fun() ->
Token = auth:generate_user_token(<<"user123">>, <<"user">>), Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>),
{ok, UserId, Role} = auth:verify_user_token(Token), {ok, UserId, Role} = eventhub_auth:verify_user_token(Token),
?assertEqual(<<"user123">>, UserId), ?assertEqual(<<"user123">>, UserId),
?assertEqual(<<"user">>, Role) ?assertEqual(<<"user">>, Role)
end}, end},
{"Generate admin token with superadmin role", {"Generate admin token with superadmin role",
fun() -> fun() ->
Token = auth:generate_admin_token(<<"admin1">>, <<"superadmin">>), Token = eventhub_auth:generate_admin_token(<<"admin1">>, <<"superadmin">>),
{ok, UserId, Role} = auth:verify_admin_token(Token), {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token),
?assertEqual(<<"admin1">>, UserId), ?assertEqual(<<"admin1">>, UserId),
?assertEqual(<<"superadmin">>, Role) ?assertEqual(<<"superadmin">>, Role)
end} end}
@@ -55,19 +55,19 @@ verify_token_errors_test_() ->
{"Invalid token signature returns error", {"Invalid token signature returns error",
fun() -> fun() ->
FakeToken = <<"not.a.valid.token">>, FakeToken = <<"not.a.valid.token">>,
?assertEqual({error, invalid_token}, auth:verify_user_token(FakeToken)), ?assertEqual({error, invalid_token}, eventhub_auth:verify_user_token(FakeToken)),
?assertEqual({error, invalid_token}, auth:verify_admin_token(FakeToken)) ?assertEqual({error, invalid_token}, eventhub_auth:verify_admin_token(FakeToken))
end}, end},
{"User token rejected by admin verifier (different secret)", {"User token rejected by admin verifier (different secret)",
fun() -> fun() ->
Token = auth:generate_user_token(<<"x">>, <<"user">>), Token = eventhub_auth:generate_user_token(<<"x">>, <<"user">>),
% Разные секреты → подпись недействительна для admin JWK % Разные секреты → подпись недействительна для admin JWK
?assertEqual({error, invalid_signature}, auth:verify_admin_token(Token)) ?assertEqual({error, invalid_signature}, eventhub_auth:verify_admin_token(Token))
end}, end},
{"Admin token rejected by user verifier (different secret)", {"Admin token rejected by user verifier (different secret)",
fun() -> fun() ->
Token = auth:generate_admin_token(<<"x">>, <<"admin">>), Token = eventhub_auth:generate_admin_token(<<"x">>, <<"admin">>),
?assertEqual({error, invalid_signature}, auth:verify_user_token(Token)) ?assertEqual({error, invalid_signature}, eventhub_auth:verify_user_token(Token))
end} end}
]}. ]}.
@@ -81,10 +81,10 @@ authenticate_user_request_test_() ->
UserMap = #{id => <<"user1">>, email => <<"u@test.com">>, role => <<"user">>}, UserMap = #{id => <<"user1">>, email => <<"u@test.com">>, role => <<"user">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end),
Req = undefined, Req = undefined,
{ok, Token, ReturnedUser} = auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>), {ok, Token, ReturnedUser} = eventhub_auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>),
?assert(is_binary(Token)), ?assert(is_binary(Token)),
?assertEqual(UserMap, ReturnedUser), ?assertEqual(UserMap, ReturnedUser),
{ok, UserId, Role} = auth:verify_user_token(Token), {ok, UserId, Role} = eventhub_auth:verify_user_token(Token),
?assertEqual(<<"user1">>, UserId), ?assertEqual(<<"user1">>, UserId),
?assertEqual(<<"user">>, Role) ?assertEqual(<<"user">>, Role)
end}, end},
@@ -92,7 +92,7 @@ authenticate_user_request_test_() ->
fun() -> fun() ->
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {error, bad_credentials} end), ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {error, bad_credentials} end),
Req = undefined, Req = undefined,
?assertEqual({error, bad_credentials}, auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>)) ?assertEqual({error, bad_credentials}, eventhub_auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>))
end} end}
]}. ]}.
@@ -106,10 +106,10 @@ authenticate_admin_request_test_() ->
AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>}, AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, AdminMap} end), ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, AdminMap} end),
Req = undefined, Req = undefined,
{ok, Token, ReturnedUser} = auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>), {ok, Token, ReturnedUser} = eventhub_auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>),
?assert(is_binary(Token)), ?assert(is_binary(Token)),
?assertEqual(AdminMap, ReturnedUser), ?assertEqual(AdminMap, ReturnedUser),
{ok, UserId, Role} = auth:verify_admin_token(Token), {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token),
?assertEqual(<<"adm1">>, UserId), ?assertEqual(<<"adm1">>, UserId),
?assertEqual(<<"superadmin">>, Role) ?assertEqual(<<"superadmin">>, Role)
end}, end},
@@ -119,15 +119,15 @@ authenticate_admin_request_test_() ->
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end),
Req = undefined, Req = undefined,
?assertEqual({error, insufficient_permissions}, ?assertEqual({error, insufficient_permissions},
auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>)) eventhub_auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>))
end}, end},
{"Moderator role is accepted as admin", {"Moderator role is accepted as admin",
fun() -> fun() ->
ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>}, ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>},
ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, ModMap} end), ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, ModMap} end),
Req = undefined, Req = undefined,
{ok, Token, _} = auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>), {ok, Token, _} = eventhub_auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>),
{ok, _, Role} = auth:verify_admin_token(Token), {ok, _, Role} = eventhub_auth:verify_admin_token(Token),
?assertEqual(<<"moderator">>, Role) ?assertEqual(<<"moderator">>, Role)
end} end}
]}. ]}.
@@ -136,4 +136,4 @@ authenticate_admin_request_test_() ->
%% Тест generate_refresh_token/1 %% Тест generate_refresh_token/1
%% ------------------------------------------------------------------ %% ------------------------------------------------------------------
generate_refresh_token_test() -> generate_refresh_token_test() ->
{_, _} = auth:generate_refresh_token(<<"anyuser">>). {_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>).

View File

@@ -1,63 +0,0 @@
-module(core_banned_word_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
setup() ->
{atomic, ok} = mnesia:start(), % правильное значение
ok = mnesia:create_table(banned_word, [
{attributes, record_info(fields, banned_word)},
{disc_copies, []},
{ram_copies, [node()]}
]).
cleanup(_) ->
mnesia:delete_table(banned_word),
mnesia:stop().
core_banned_word_test_() ->
{setup, fun setup/0, fun cleanup/1, [
{"Add banned word success", fun test_add_success/0},
{"Add banned word already exists", fun test_add_already_exists/0},
{"Remove banned word success", fun test_remove_success/0},
{"Remove banned word not found", fun test_remove_not_found/0},
{"Update banned word success", fun test_update_success/0},
{"Update banned word not found", fun test_update_not_found/0},
{"List banned words returns all records", fun test_list/0}
]}.
test_add_success() ->
{ok, BW} = core_banned_words:add_banned_word(<<"badword">>, <<"admin1">>),
?assertEqual(<<"badword">>, BW#banned_word.word),
?assertEqual(<<"admin1">>, BW#banned_word.added_by),
?assert(is_binary(BW#banned_word.id)),
?assert(size(BW#banned_word.id) > 0),
?assertEqual(1, length(core_banned_words:list_banned_words())).
test_add_already_exists() ->
{ok, _} = core_banned_words:add_banned_word(<<"spam">>, <<"admin1">>),
{error, already_exists} = core_banned_words:add_banned_word(<<"spam">>, <<"admin2">>).
test_remove_success() ->
{ok, _} = core_banned_words:add_banned_word(<<"badword">>, <<"admin1">>),
{ok, deleted} = core_banned_words:remove_banned_word(<<"badword">>),
?assertEqual([], core_banned_words:list_banned_words()).
test_remove_not_found() ->
?assertEqual({error, not_found}, core_banned_words:remove_banned_word(<<"unknown">>)).
test_update_success() ->
{ok, _} = core_banned_words:add_banned_word(<<"oldword">>, <<"admin1">>),
{ok, BW} = core_banned_words:update_banned_word(<<"oldword">>, <<"newword">>),
?assertEqual(<<"newword">>, BW#banned_word.word),
?assertEqual([<<"newword">>], [W#banned_word.word || W <- core_banned_words:list_banned_words()]).
test_update_not_found() ->
?assertEqual({error, not_found}, core_banned_words:update_banned_word(<<"unknown">>, <<"newword">>)).
test_list() ->
{ok, _} = core_banned_words:add_banned_word(<<"word1">>, <<"adm1">>),
{ok, _} = core_banned_words:add_banned_word(<<"word2">>, <<"adm2">>),
List = core_banned_words:list_banned_words(),
?assertEqual(2, length(List)),
?assert(lists:any(fun(W) -> W#banned_word.word == <<"word1">> end, List)),
?assert(lists:any(fun(W) -> W#banned_word.word == <<"word2">> end, List)).

View File

@@ -0,0 +1,87 @@
-module(core_banned_words_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
%% ----------------------------------------------------------------
%% Фикстуры
%% ----------------------------------------------------------------
setup() ->
% Гарантированно останавливаем Mnesia (если уже запущена)
catch mnesia:stop(),
% Запускаем Mnesia (первый раз вернёт {atomic, ok}, потом ok)
case mnesia:start() of
{atomic, ok} -> ok;
ok -> ok
end,
% Создаём таблицу (всегда возвращает {atomic, ok})
{atomic, ok} = mnesia:create_table(banned_word, [
{attributes, record_info(fields, banned_word)},
{ram_copies, [node()]}
]),
ok.
cleanup(_) ->
mnesia:delete_table(banned_word),
mnesia:stop().
%% ----------------------------------------------------------------
%% Тесты
%% ----------------------------------------------------------------
core_banned_words_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"Add banned word (success)", fun test_add_banned_word/0},
{"Add banned word (duplicate)", fun test_add_duplicate/0},
{"Remove banned word (success)", fun test_remove_banned_word/0},
{"Remove banned word (not found)", fun test_remove_not_found/0},
{"Update banned word (success)", fun test_update_banned_word/0},
{"Update banned word (not found)", fun test_update_not_found/0},
{"List banned words", fun test_list_banned_words/0}
]}.
%% ── Добавление ───────────────────────────────────────────
test_add_banned_word() ->
Word = <<"badword">>,
AddedBy = <<"admin1">>,
{ok, BW} = core_banned_words:add_banned_word(Word, AddedBy),
?assertEqual(Word, BW#banned_word.word),
?assertEqual(AddedBy, BW#banned_word.added_by),
?assert(is_binary(BW#banned_word.id)),
?assert(size(BW#banned_word.id) > 0).
test_add_duplicate() ->
Word = <<"duplicate">>,
{ok, _} = core_banned_words:add_banned_word(Word, <<"admin1">>),
?assertEqual({error, already_exists}, core_banned_words:add_banned_word(Word, <<"admin2">>)).
%% ── Удаление ─────────────────────────────────────────────
test_remove_banned_word() ->
Word = <<"to_remove">>,
{ok, _} = core_banned_words:add_banned_word(Word, <<"admin1">>),
{ok, deleted} = core_banned_words:remove_banned_word(Word),
?assertEqual([], core_banned_words:list_banned_words()).
test_remove_not_found() ->
?assertEqual({error, not_found}, core_banned_words:remove_banned_word(<<"nonexistent">>)).
%% ── Обновление ───────────────────────────────────────────
test_update_banned_word() ->
OldWord = <<"old_word">>,
NewWord = <<"new_word">>,
{ok, _} = core_banned_words:add_banned_word(OldWord, <<"admin1">>),
{ok, Updated} = core_banned_words:update_banned_word(OldWord, NewWord),
?assertEqual(NewWord, Updated#banned_word.word),
?assertEqual([NewWord], [W#banned_word.word || W <- core_banned_words:list_banned_words()]).
test_update_not_found() ->
?assertEqual({error, not_found}, core_banned_words:update_banned_word(<<"unknown">>, <<"new">>)).
%% ── Список ───────────────────────────────────────────────
test_list_banned_words() ->
{ok, _} = core_banned_words:add_banned_word(<<"word1">>, <<"adm1">>),
{ok, _} = core_banned_words:add_banned_word(<<"word2">>, <<"adm2">>),
{ok, _} = core_banned_words:add_banned_word(<<"word3">>, <<"adm3">>),
List = core_banned_words:list_banned_words(),
?assertEqual(3, length(List)),
?assert(lists:any(fun(W) -> W#banned_word.word == <<"word1">> end, List)),
?assert(lists:any(fun(W) -> W#banned_word.word == <<"word2">> end, List)),
?assert(lists:any(fun(W) -> W#banned_word.word == <<"word3">> end, List)).

View File

@@ -2,9 +2,16 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
%% ----------------------------------------------------------------
%% Фикстуры
%% ----------------------------------------------------------------
setup() -> setup() ->
mnesia:start(), catch mnesia:stop(),
mnesia:create_table(ticket, [ case mnesia:start() of
{atomic, ok} -> ok;
ok -> ok
end,
{atomic, ok} = mnesia:create_table(ticket, [
{attributes, record_info(fields, ticket)}, {attributes, record_info(fields, ticket)},
{ram_copies, [node()]} {ram_copies, [node()]}
]), ]),
@@ -12,110 +19,118 @@ setup() ->
cleanup(_) -> cleanup(_) ->
mnesia:delete_table(ticket), mnesia:delete_table(ticket),
mnesia:stop(), mnesia:stop().
ok.
%% ----------------------------------------------------------------
%% Тесты
%% ----------------------------------------------------------------
core_ticket_test_() -> core_ticket_test_() ->
{foreach, {foreach, fun setup/0, fun cleanup/1, [
fun setup/0, {"Create ticket and retrieve it", fun test_create_and_get/0},
fun cleanup/1, {"Update ticket status", fun test_update_status/0},
[ {"Delete ticket and verify removal", fun test_delete_ticket/0},
{"Create ticket test", fun test_create_ticket/0}, {"List all tickets returns created ones", fun test_list_all/0},
{"Update existing ticket test", fun test_update_ticket/0}, {"List tickets by user filters correctly", fun test_list_by_user/0},
{"Get ticket by id test", fun test_get_by_id/0}, {"Get ticket stats reflects real counts", fun test_stats/0},
{"Get ticket by error hash test", fun test_get_by_error_hash/0}, {"Update ticket with unknown id fails", fun test_update_not_found/0},
{"List all tickets test", fun test_list_all/0}, {"Delete ticket with unknown id fails", fun test_delete_not_found/0},
{"List by status test", fun test_list_by_status/0}, {"Get ticket with unknown id fails", fun test_get_not_found/0}
{"Update status test", fun test_update_status/0},
{"Assign ticket test", fun test_assign_ticket/0},
{"Add resolution test", fun test_add_resolution/0}
]}. ]}.
test_create_ticket() -> %% ── Вспомогательная функция для создания тикета ─────────
ErrorMsg = <<"Test error">>, make_ticket(ErrorMsg) ->
Stacktrace = <<"line 1\nline 2">>, Data = #{
Context = #{user_id => <<"user123">>}, <<"error_message">> => list_to_binary(ErrorMsg),
<<"stacktrace">> => <<"trace">>,
<<"reporter_id">> => <<"user123">>,
<<"status">> => <<"open">>
},
{ok, Ticket} = core_ticket:create_ticket(Data),
Ticket.
{ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), %% ── Тесты ─────────────────────────────────────────────────
?assertEqual(ErrorMsg, Ticket#ticket.error_message), test_create_and_get() ->
?assertEqual(Stacktrace, Ticket#ticket.stacktrace), Ticket = make_ticket("Bug1"),
?assertEqual(1, Ticket#ticket.count),
?assertEqual(open, Ticket#ticket.status),
?assert(is_binary(Ticket#ticket.id)), ?assert(is_binary(Ticket#ticket.id)),
?assert(is_binary(Ticket#ticket.error_hash)). {ok, Retrieved} = core_ticket:get_by_id(Ticket#ticket.id),
?assertEqual(Ticket#ticket.id, Retrieved#ticket.id).
test_update_ticket() ->
ErrorMsg = <<"Test error">>,
Stacktrace = <<"line 1">>,
Context = #{},
{ok, Ticket1} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context),
?assertEqual(1, Ticket1#ticket.count),
{ok, Ticket2} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context),
?assertEqual(Ticket1#ticket.id, Ticket2#ticket.id),
?assertEqual(2, Ticket2#ticket.count),
?assert(Ticket2#ticket.last_seen >= Ticket1#ticket.last_seen).
test_get_by_id() ->
{ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}),
{ok, Found} = core_ticket:get_by_id(Ticket#ticket.id),
?assertEqual(Ticket#ticket.id, Found#ticket.id),
{error, not_found} = core_ticket:get_by_id(<<"nonexistent">>).
test_get_by_error_hash() ->
ErrorMsg = <<"Unique error">>,
Stacktrace = <<"stack">>,
{ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, #{}),
{ok, Found} = core_ticket:get_by_error_hash(Ticket#ticket.error_hash),
?assertEqual(Ticket#ticket.id, Found#ticket.id),
{error, not_found} = core_ticket:get_by_error_hash(<<"badhash">>).
test_list_all() ->
{ok, _} = core_ticket:create_or_update(<<"Error 1">>, <<"">>, #{}),
{ok, _} = core_ticket:create_or_update(<<"Error 2">>, <<"">>, #{}),
{ok, _} = core_ticket:create_or_update(<<"Error 3">>, <<"">>, #{}),
{ok, Tickets} = core_ticket:list_all(),
?assertEqual(3, length(Tickets)).
test_list_by_status() ->
{ok, _T1} = core_ticket:create_or_update(<<"E1">>, <<"">>, #{}),
{ok, T2} = core_ticket:create_or_update(<<"E2">>, <<"">>, #{}),
core_ticket:update_status(T2#ticket.id, resolved),
{ok, Open} = core_ticket:list_by_status(open),
?assertEqual(1, length(Open)),
{ok, Resolved} = core_ticket:list_by_status(resolved),
?assertEqual(1, length(Resolved)).
test_update_status() -> test_update_status() ->
{ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), Ticket = make_ticket("Bug2"),
{ok, Updated} = core_ticket:update_ticket(Ticket#ticket.id,
#{<<"status">> => <<"closed">>}),
?assertEqual(closed, Updated#ticket.status),
{ok, Stored} = core_ticket:get_by_id(Ticket#ticket.id),
?assertEqual(closed, Stored#ticket.status).
{ok, Updated} = core_ticket:update_status(Ticket#ticket.id, in_progress), test_delete_ticket() ->
?assertEqual(in_progress, Updated#ticket.status), Ticket = make_ticket("Bug3"),
Id = Ticket#ticket.id,
{ok, deleted} = core_ticket:delete_ticket(Id),
% Проверяем, что тикет больше не читается
?assertMatch({error, not_found}, core_ticket:get_by_id(Id)).
{ok, Resolved} = core_ticket:update_status(Ticket#ticket.id, resolved), test_list_all() ->
?assertEqual(resolved, Resolved#ticket.status). T1 = make_ticket("E1"),
T2 = make_ticket("E2"),
All = core_ticket:list_all(),
?assert(length(All) >= 2),
Ids = [T#ticket.id || T <- All],
?assert(lists:member(T1#ticket.id, Ids)),
?assert(lists:member(T2#ticket.id, Ids)).
test_assign_ticket() -> test_list_by_user() ->
AdminId = <<"admin123">>, % Создаём тикет от пользователя test_user
{ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), Data = #{
<<"error_message">> => <<"from_test_user">>,
<<"stacktrace">> => <<"trace">>,
<<"reporter_id">> => <<"test_user">>,
<<"status">> => <<"open">>
},
{ok, T1} = core_ticket:create_ticket(Data),
% Ещё один тикет от другого пользователя
DataOther = #{
<<"error_message">> => <<"other">>,
<<"stacktrace">> => <<"trace">>,
<<"reporter_id">> => <<"other_user">>,
<<"status">> => <<"open">>
},
{ok, _T2} = core_ticket:create_ticket(DataOther),
% list_by_user("test_user") должен вернуть ровно один тикет (T1)
UserTickets = core_ticket:list_by_user(<<"test_user">>),
?assertEqual(1, length(UserTickets)),
?assertEqual(T1#ticket.id, (hd(UserTickets))#ticket.id).
{ok, Assigned} = core_ticket:assign(Ticket#ticket.id, AdminId), test_stats() ->
?assertEqual(AdminId, Assigned#ticket.assigned_to), Data1 = #{
?assertEqual(in_progress, Assigned#ticket.status). <<"error_message">> => <<"stat1">>,
<<"stacktrace">> => <<"trace">>,
<<"reporter_id">> => <<"reporter123">>,
<<"status">> => <<"open">>
},
Data2 = #{
<<"error_message">> => <<"stat2">>,
<<"stacktrace">> => <<"trace">>,
<<"reporter_id">> => <<"reporter456">>,
<<"status">> => <<"open">>
},
{ok, _} = core_ticket:create_ticket(Data1),
{ok, _} = core_ticket:create_ticket(Data2),
Stats = core_ticket:stats(),
?assert(is_map(Stats)),
?assert(maps:is_key(open, Stats)),
?assert(maps:is_key(total, Stats)),
% Проверяем, что общее количество тикетов не меньше 2
Total = maps:get(total, Stats),
?assert(Total >= 2).
test_add_resolution() -> test_update_not_found() ->
Note = <<"Fixed in version 1.0">>, {error, not_found} = core_ticket:update_ticket(<<"nonexistent">>,
{ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), #{<<"status">> => <<"closed">>}).
{ok, Updated} = core_ticket:add_resolution(Ticket#ticket.id, Note), test_delete_not_found() ->
?assertEqual(Note, Updated#ticket.resolution_note). {error, not_found} = core_ticket:delete_ticket(<<"nonexistent">>).
test_get_not_found() ->
{error, not_found} = core_ticket:get_by_id(<<"nonexistent">>).

View File

@@ -4,9 +4,6 @@
-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). -define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>).
-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). -define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>).
%% ------------------------------------------------------------------
%% Фикстуры
%% ------------------------------------------------------------------
setup() -> setup() ->
application:set_env(eventhub, jwt_secret, ?JWT_SECRET), application:set_env(eventhub, jwt_secret, ?JWT_SECRET),
application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET),
@@ -18,9 +15,6 @@ cleanup(_) ->
application:unset_env(eventhub, admin_jwt_secret), application:unset_env(eventhub, admin_jwt_secret),
application:stop(jose). application:stop(jose).
%% ------------------------------------------------------------------
%% Тесты
%% ------------------------------------------------------------------
logic_auth_test_() -> logic_auth_test_() ->
[ [
{"Password hash test", fun test_password_hash/0}, {"Password hash test", fun test_password_hash/0},
@@ -31,7 +25,6 @@ logic_auth_test_() ->
]} ]}
]. ].
%% ── Хеширование паролей (остаётся в logic_auth) ──────────────────
test_password_hash() -> test_password_hash() ->
Password = <<"secret123">>, Password = <<"secret123">>,
{ok, Hash} = logic_auth:hash_password(Password), {ok, Hash} = logic_auth:hash_password(Password),
@@ -39,27 +32,23 @@ test_password_hash() ->
{ok, true} = logic_auth:verify_password(Password, Hash), {ok, true} = logic_auth:verify_password(Password, Hash),
{ok, false} = logic_auth:verify_password(<<"wrong">>, Hash). {ok, false} = logic_auth:verify_password(<<"wrong">>, Hash).
%% ── JWT тесты (перенесены в auth) ─────────────────────────────────
test_jwt() -> test_jwt() ->
UserId = <<"user123">>, UserId = <<"user123">>,
Role = <<"user">>, Role = <<"user">>,
Token = auth:generate_user_token(UserId, Role), Token = eventhub_auth:generate_user_token(UserId, Role),
?assert(is_binary(Token)), ?assert(is_binary(Token)),
{ok, ReturnedUserId, ReturnedRole} = auth:verify_user_token(Token), {ok, ReturnedUserId, ReturnedRole} = eventhub_auth:verify_user_token(Token),
?assertEqual(UserId, ReturnedUserId), ?assertEqual(UserId, ReturnedUserId),
?assertEqual(Role, ReturnedRole), ?assertEqual(Role, ReturnedRole),
% Проверка невалидного токена {error, invalid_token} = eventhub_auth:verify_user_token(<<"invalid.token.here">>).
{error, invalid_token} = auth:verify_user_token(<<"invalid.token.here">>).
test_jwt_expired() -> test_jwt_expired() ->
% Тест на истечение срока пока пропущен, так как требует мока времени
ok. ok.
%% ── Refresh token (перенесён в auth) ────────────────────────────
test_refresh_token() -> test_refresh_token() ->
{Token, ExpiresAt} = auth:generate_refresh_token(<<"user123">>), {Token, ExpiresAt} = eventhub_auth:generate_refresh_token(<<"user123">>),
?assert(is_binary(Token)), ?assert(is_binary(Token)),
?assert(size(Token) >= 32), ?assert(size(Token) >= 32),
?assert(is_integer(ExpiresAt)), ?assert(is_tuple(ExpiresAt)),
Now = os:system_time(second), Now = calendar:universal_time(),
?assert(ExpiresAt > Now). ?assert(ExpiresAt > Now).

View File

@@ -2,13 +2,25 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
%% ----------------------------------------------------------------
%% Фикстуры
%% ----------------------------------------------------------------
setup() -> setup() ->
mnesia:start(), catch mnesia:stop(),
mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), case mnesia:start() of
mnesia:create_table(calendar, [{attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]), {atomic, ok} -> ok;
mnesia:create_table(event, [{attributes, record_info(fields, event)}, {ram_copies, [node()]}]), ok -> ok
mnesia:create_table(report, [{attributes, record_info(fields, report)}, {ram_copies, [node()]}]), end,
mnesia:create_table(banned_word, [{attributes, record_info(fields, banned_word)}, {ram_copies, [node()]}]), {atomic, ok} = mnesia:create_table(user, [
{attributes, record_info(fields, user)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(calendar, [
{attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(event, [
{attributes, record_info(fields, event)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(report, [
{attributes, record_info(fields, report)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(banned_word, [
{attributes, record_info(fields, banned_word)}, {ram_copies, [node()]}]),
ok. ok.
cleanup(_) -> cleanup(_) ->
@@ -17,14 +29,13 @@ cleanup(_) ->
mnesia:delete_table(event), mnesia:delete_table(event),
mnesia:delete_table(calendar), mnesia:delete_table(calendar),
mnesia:delete_table(user), mnesia:delete_table(user),
mnesia:stop(), mnesia:stop().
ok.
%% ----------------------------------------------------------------
%% Тесты
%% ----------------------------------------------------------------
logic_moderation_test_() -> logic_moderation_test_() ->
{foreach, {foreach, fun setup/0, fun cleanup/1, [
fun setup/0,
fun cleanup/1,
[
{"Create report test", fun test_create_report/0}, {"Create report test", fun test_create_report/0},
{"Get reports test", fun test_get_reports/0}, {"Get reports test", fun test_get_reports/0},
{"Resolve report test", fun test_resolve_report/0}, {"Resolve report test", fun test_resolve_report/0},
@@ -36,10 +47,18 @@ logic_moderation_test_() ->
{"Check content test", fun test_check_content/0} {"Check content test", fun test_check_content/0}
]}. ]}.
%% ── Вспомогательные функции ──────────────────────────────
create_test_user(Role) -> create_test_user(Role) ->
UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}),
User = #user{id = UserId, email = <<UserId/binary, "@test.com">>, password_hash = <<"hash">>, User = #user{
role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, id = UserId,
email = <<>>,
password_hash = <<"hash">>,
role = Role,
status = active,
created_at = calendar:universal_time(),
updated_at = calendar:universal_time()
},
mnesia:dirty_write(User), mnesia:dirty_write(User),
UserId. UserId.
@@ -48,15 +67,16 @@ create_test_calendar(OwnerId) ->
Calendar#calendar.id. Calendar#calendar.id.
create_test_event(CalendarId) -> create_test_event(CalendarId) ->
{ok, Event} = core_event:create(CalendarId, <<"Event">>, {{2026, 6, 1}, {10, 0, 0}}, 60), {ok, Event} = core_event:create(CalendarId, <<"Event">>,
{{2026, 6, 1}, {10, 0, 0}}, 60),
Event#event.id. Event#event.id.
%% ── Тесты ─────────────────────────────────────────────────
test_create_report() -> test_create_report() ->
ReporterId = create_test_user(user), ReporterId = create_test_user(user),
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
{ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"Bad content">>), {ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"Bad content">>),
?assertEqual(ReporterId, Report#report.reporter_id), ?assertEqual(ReporterId, Report#report.reporter_id),
?assertEqual(pending, Report#report.status). ?assertEqual(pending, Report#report.status).
@@ -67,9 +87,7 @@ test_get_reports() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
{ok, _} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>),
{ok, Reports} = logic_moderation:get_reports(AdminId), {ok, Reports} = logic_moderation:get_reports(AdminId),
?assertEqual(1, length(Reports)). ?assertEqual(1, length(Reports)).
@@ -79,7 +97,6 @@ test_resolve_report() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
{ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>), {ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>),
{ok, Resolved} = logic_moderation:resolve_report(AdminId, Report#report.id, reviewed), {ok, Resolved} = logic_moderation:resolve_report(AdminId, Report#report.id, reviewed),
?assertEqual(reviewed, Resolved#report.status), ?assertEqual(reviewed, Resolved#report.status),
@@ -87,14 +104,15 @@ test_resolve_report() ->
test_add_banned_word() -> test_add_banned_word() ->
AdminId = create_test_user(admin), AdminId = create_test_user(admin),
{ok, _} = logic_moderation:add_banned_word(AdminId, <<"badword">>), {ok, BW} = logic_moderation:add_banned_word(AdminId, <<"badword">>),
?assert(core_banned_word:is_banned(<<"badword">>)). ?assertEqual(<<"badword">>, BW#banned_word.word),
?assertEqual(AdminId, BW#banned_word.added_by).
test_remove_banned_word() -> test_remove_banned_word() ->
AdminId = create_test_user(admin), AdminId = create_test_user(admin),
{ok, _} = logic_moderation:add_banned_word(AdminId, <<"badword">>), {ok, _} = logic_moderation:add_banned_word(AdminId, <<"badword">>),
{ok, removed} = logic_moderation:remove_banned_word(AdminId, <<"badword">>), {ok, deleted} = logic_moderation:remove_banned_word(AdminId, <<"badword">>),
?assertNot(core_banned_word:is_banned(<<"badword">>)). ?assertEqual([], core_banned_words:list_banned_words()).
test_auto_freeze() -> test_auto_freeze() ->
Reporter1 = create_test_user(user), Reporter1 = create_test_user(user),
@@ -103,12 +121,9 @@ test_auto_freeze() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
% 3 жалобы должны заморозить событие
{ok, _} = logic_moderation:create_report(Reporter1, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(Reporter1, event, EventId, <<"">>),
{ok, _} = logic_moderation:create_report(Reporter2, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(Reporter2, event, EventId, <<"">>),
{ok, _} = logic_moderation:create_report(Reporter3, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(Reporter3, event, EventId, <<"">>),
{ok, Event} = core_event:get_by_id(EventId), {ok, Event} = core_event:get_by_id(EventId),
?assertEqual(frozen, Event#event.status). ?assertEqual(frozen, Event#event.status).
@@ -116,10 +131,8 @@ test_freeze_calendar() ->
AdminId = create_test_user(admin), AdminId = create_test_user(admin),
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
{ok, Frozen} = logic_moderation:freeze_calendar(AdminId, CalendarId), {ok, Frozen} = logic_moderation:freeze_calendar(AdminId, CalendarId),
?assertEqual(frozen, Frozen#calendar.status), ?assertEqual(frozen, Frozen#calendar.status),
{ok, Unfrozen} = logic_moderation:unfreeze_calendar(AdminId, CalendarId), {ok, Unfrozen} = logic_moderation:unfreeze_calendar(AdminId, CalendarId),
?assertEqual(active, Unfrozen#calendar.status). ?assertEqual(active, Unfrozen#calendar.status).
@@ -128,19 +141,15 @@ test_freeze_event() ->
OwnerId = create_test_user(user), OwnerId = create_test_user(user),
CalendarId = create_test_calendar(OwnerId), CalendarId = create_test_calendar(OwnerId),
EventId = create_test_event(CalendarId), EventId = create_test_event(CalendarId),
{ok, Frozen} = logic_moderation:freeze_event(AdminId, EventId), {ok, Frozen} = logic_moderation:freeze_event(AdminId, EventId),
?assertEqual(frozen, Frozen#event.status), ?assertEqual(frozen, Frozen#event.status),
{ok, Unfrozen} = logic_moderation:unfreeze_event(AdminId, EventId), {ok, Unfrozen} = logic_moderation:unfreeze_event(AdminId, EventId),
?assertEqual(active, Unfrozen#event.status). ?assertEqual(active, Unfrozen#event.status).
test_check_content() -> test_check_content() ->
AdminId = create_test_user(admin), AdminId = create_test_user(admin),
{ok, _} = logic_moderation:add_banned_word(AdminId, <<"bad">>), {ok, _} = logic_moderation:add_banned_word(AdminId, <<"bad">>),
?assertNot(logic_moderation:check_content(<<"Hello">>)), ?assertNot(logic_moderation:check_content(<<"Hello">>)),
?assert(logic_moderation:check_content(<<"This is bad">>)), ?assert(logic_moderation:check_content(<<"This is bad">>)),
?assertEqual(<<"Hello">>, logic_moderation:auto_moderate(<<"Hello">>)), ?assertEqual(<<"Hello">>, logic_moderation:auto_moderate(<<"Hello">>)),
?assertEqual(<<"This is ***">>, logic_moderation:auto_moderate(<<"This is bad">>)). ?assertEqual(<<"This is ***">>, logic_moderation:auto_moderate(<<"This is bad">>)).

View File

@@ -2,104 +2,106 @@
-include_lib("eunit/include/eunit.hrl"). -include_lib("eunit/include/eunit.hrl").
-include("records.hrl"). -include("records.hrl").
%% ----------------------------------------------------------------
%% Фикстуры
%% ----------------------------------------------------------------
setup() -> setup() ->
mnesia:start(), catch mnesia:stop(),
mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), case mnesia:start() of
mnesia:create_table(ticket, [{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), {atomic, ok} -> ok;
ok -> ok
end,
{atomic, ok} = mnesia:create_table(user, [
{attributes, record_info(fields, user)}, {ram_copies, [node()]}]),
{atomic, ok} = mnesia:create_table(ticket, [
{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]),
% Создаём админа и обычного пользователя
Admin = #user{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>,
role = admin, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>,
role = user, status = active,
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
mnesia:dirty_write(Admin),
mnesia:dirty_write(User),
ok. ok.
cleanup(_) -> cleanup(_) ->
mnesia:delete_table(ticket),
mnesia:delete_table(user), mnesia:delete_table(user),
mnesia:stop(), mnesia:delete_table(ticket),
ok. mnesia:stop().
%% ----------------------------------------------------------------
%% Тесты
%% ----------------------------------------------------------------
logic_ticket_test_() -> logic_ticket_test_() ->
{foreach, {foreach, fun setup/0, fun cleanup/1, [
fun setup/0, {"Report error creates ticket", fun test_report_error/0},
fun cleanup/1, {"Report duplicate error increments count", fun test_report_duplicate/0},
[ {"List tickets as admin", fun test_list_tickets/0},
{"Report error test", fun test_report_error/0}, {"List tickets as non-admin returns error", fun test_list_tickets_forbidden/0},
{"List tickets admin only", fun test_list_tickets_admin_only/0}, {"Update status as admin", fun test_update_status/0},
{"Update status test", fun test_update_status/0}, {"Assign ticket as admin", fun test_assign_ticket/0},
{"Assign ticket test", fun test_assign_ticket/0}, {"Resolve ticket as admin", fun test_resolve_ticket/0},
{"Resolve ticket test", fun test_resolve_ticket/0}, {"Close ticket as admin", fun test_close_ticket/0},
{"Close ticket test", fun test_close_ticket/0}, {"Get statistics as admin", fun test_get_statistics/0}
{"Get statistics test", fun test_get_statistics/0}
]}. ]}.
create_test_user(Role) -> %% --- Вспомогательная функция для создания тикета ---
UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), report(ErrorMsg) ->
User = #user{id = UserId, email = <<UserId/binary, "@test.com">>, password_hash = <<"hash">>, logic_ticket:report_error(ErrorMsg, <<"stack">>, #{}).
role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
mnesia:dirty_write(User), %% --- Тесты ---
UserId.
test_report_error() -> test_report_error() ->
{ok, Ticket} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), {ok, Ticket} = report(<<"Error1">>),
?assertEqual(<<"Test error">>, Ticket#ticket.error_message), ?assertEqual(<<"Error1">>, Ticket#ticket.error_message),
?assertEqual(1, Ticket#ticket.count), ?assertEqual(1, Ticket#ticket.count).
{ok, Ticket2} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), test_report_duplicate() ->
?assertEqual(2, Ticket2#ticket.count). {ok, T1} = report(<<"Dup">>),
?assertEqual(1, T1#ticket.count),
{ok, T2} = report(<<"Dup">>),
?assertEqual(2, T2#ticket.count),
% Проверяем, что это тот же тикет, а не новый
?assertEqual(T1#ticket.id, T2#ticket.id).
test_list_tickets_admin_only() -> test_list_tickets() ->
AdminId = create_test_user(admin), {ok, _} = report(<<"E1">>),
UserId = create_test_user(user), Tickets = logic_ticket:list_tickets(<<"admin1">>),
?assert(length(Tickets) =:= 1).
{ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), test_list_tickets_forbidden() ->
{ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), {error, access_denied} = logic_ticket:list_tickets(<<"user1">>).
{ok, Tickets} = logic_ticket:list_tickets(AdminId),
?assertEqual(2, length(Tickets)),
{error, access_denied} = logic_ticket:list_tickets(UserId).
test_update_status() -> test_update_status() ->
AdminId = create_test_user(admin), {ok, Ticket} = report(<<"E2">>),
UserId = create_test_user(user), {ok, Updated} = logic_ticket:update_status(<<"admin1">>, Ticket#ticket.id, <<"closed">>),
{ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), ?assertEqual(closed, Updated#ticket.status).
{ok, Updated} = logic_ticket:update_status(AdminId, Ticket#ticket.id, in_progress),
?assertEqual(in_progress, Updated#ticket.status),
{error, access_denied} = logic_ticket:update_status(UserId, Ticket#ticket.id, resolved).
test_assign_ticket() -> test_assign_ticket() ->
AdminId = create_test_user(admin), {ok, Ticket} = report(<<"E3">>),
AssignToId = create_test_user(admin), {ok, Updated} = logic_ticket:assign_ticket(<<"admin1">>, Ticket#ticket.id, <<"dev1">>),
{ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), ?assertEqual(<<"dev1">>, Updated#ticket.assigned_to).
{ok, Assigned} = logic_ticket:assign_ticket(AdminId, Ticket#ticket.id, AssignToId),
?assertEqual(AssignToId, Assigned#ticket.assigned_to),
?assertEqual(in_progress, Assigned#ticket.status).
test_resolve_ticket() -> test_resolve_ticket() ->
AdminId = create_test_user(admin), {ok, Ticket} = report(<<"E4">>),
{ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), {ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>),
?assertEqual(closed, Updated#ticket.status),
{ok, Resolved} = logic_ticket:resolve_ticket(AdminId, Ticket#ticket.id, <<"Fixed">>), ?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note).
?assertEqual(<<"Fixed">>, Resolved#ticket.resolution_note),
?assertEqual(resolved, Resolved#ticket.status).
test_close_ticket() -> test_close_ticket() ->
AdminId = create_test_user(admin), {ok, Ticket} = report(<<"E5">>),
{ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), {ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id),
?assertEqual(closed, Updated#ticket.status).
{ok, Closed} = logic_ticket:close_ticket(AdminId, Ticket#ticket.id),
?assertEqual(closed, Closed#ticket.status).
test_get_statistics() -> test_get_statistics() ->
AdminId = create_test_user(admin), {ok, _} = report(<<"E6">>),
{ok, _} = report(<<"E7">>),
{ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), {ok, T3} = report(<<"E8">>),
{ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), logic_ticket:close_ticket(<<"admin1">>, T3#ticket.id),
{ok, T3} = logic_ticket:report_error(<<"E3">>, <<"">>, #{}), Stats = logic_ticket:get_statistics(<<"admin1">>),
logic_ticket:update_status(AdminId, T3#ticket.id, resolved),
Stats = logic_ticket:get_statistics(AdminId),
?assertEqual(3, maps:get(total_tickets, Stats)), ?assertEqual(3, maps:get(total_tickets, Stats)),
?assertEqual(2, maps:get(open, Stats)), ?assertEqual(2, maps:get(open, Stats)),
?assertEqual(1, maps:get(resolved, Stats)), ?assertEqual(1, maps:get(closed, Stats)),
?assertEqual(3, maps:get(total_errors, Stats)). ?assertEqual(3, maps:get(total_errors, Stats)).