fix(backend): IP в аудите, closed_at тикетов и admin_activity в статистике. Refs EventHub/EventHubBack#27 #28 #29
This commit is contained in:
@@ -1,17 +1,24 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Rebar3 на WSL+Windows иногда оставляет symlink вместо каталога.
|
# Rebar3 на WSL+ext4 _build оставляет битые symlink unit|api (относительный путь не резолвится).
|
||||||
TEST_DIR="/home/alexc/eventhub-wsl-data/_build/test/lib/eventhub/test"
|
# Заменяем на полную копию из репозитория.
|
||||||
|
|
||||||
|
PROJ="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
BUILD_DIR="$(readlink -f "${PROJ}/_build" 2>/dev/null || echo "${PROJ}/_build")"
|
||||||
|
TEST_DIR="${BUILD_DIR}/test/lib/eventhub/test"
|
||||||
|
|
||||||
mkdir -p "${TEST_DIR}"
|
mkdir -p "${TEST_DIR}"
|
||||||
|
|
||||||
for name in unit api; do
|
for name in unit api; do
|
||||||
target="${TEST_DIR}/${name}"
|
target="${TEST_DIR}/${name}"
|
||||||
if [ -L "${target}" ] || [ -f "${target}" ]; then
|
src="${PROJ}/test/${name}"
|
||||||
rm -f "${target}"
|
if [ ! -d "${src}" ]; then
|
||||||
|
echo "fix-rebar-unit-symlink: нет каталога ${src}" >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
mkdir -p "${target}"
|
rm -rf "${target}"
|
||||||
echo "Fixed: ${target}"
|
cp -a "${src}" "${target}"
|
||||||
|
echo "Copied: ${src} -> ${target}"
|
||||||
ls -ld "${target}"
|
ls -ld "${target}"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Запуск тестов только в Linux (WSL dev): asdf OTP 28 + rebar 3.27.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJ="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
# shellcheck source=scripts/wsl-dev-env.sh
|
||||||
|
source "${SCRIPT_DIR}/wsl-dev-env.sh"
|
||||||
|
|
||||||
|
cd "${PROJ}"
|
||||||
|
bash "${SCRIPT_DIR}/wsl-ext4-dirs.sh" 2>/dev/null || true
|
||||||
|
bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh"
|
||||||
|
|
||||||
|
echo "=== env ==="
|
||||||
|
erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().'
|
||||||
|
rebar3 --version
|
||||||
|
echo "rebar3: $(command -v rebar3)"
|
||||||
|
echo "erl: $(command -v erl)"
|
||||||
|
|
||||||
|
echo "=== compile ==="
|
||||||
|
rebar3 compile
|
||||||
|
|
||||||
|
bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh"
|
||||||
|
|
||||||
|
echo "=== eunit: admin_utils_tests ==="
|
||||||
|
rebar3 eunit --module=admin_utils_tests --verbose
|
||||||
|
|
||||||
|
echo "=== eunit: logic_ticket_tests ==="
|
||||||
|
rebar3 eunit --module=logic_ticket_tests --verbose
|
||||||
|
|
||||||
|
bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh"
|
||||||
|
|
||||||
|
port_busy() {
|
||||||
|
ss -tln 2>/dev/null | grep -q ":$1 "
|
||||||
|
}
|
||||||
|
|
||||||
|
run_ct() {
|
||||||
|
local case_name="$1"
|
||||||
|
echo "=== ct: ${case_name} ==="
|
||||||
|
rebar3 ct --suite=api_admins_SUITE --case="${case_name}" --verbose
|
||||||
|
}
|
||||||
|
|
||||||
|
if port_busy 8080 || port_busy 8445; then
|
||||||
|
echo "=== ct: SKIP (порты 8080/8445 заняты — остановите eventhub и повторите) ==="
|
||||||
|
ss -tln 2>/dev/null | grep -E ':8080|:8445' || true
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_ct admin_test_stats
|
||||||
|
bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh"
|
||||||
|
run_ct admin_test_audit
|
||||||
|
|
||||||
|
echo "=== ALL PASSED ==="
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Linux-only WSL dev: asdf OTP 28 + rebar 3.27 (без Windows erl/rebar3 в PATH).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ASDF_DIR="${HOME}/.asdf"
|
||||||
|
ERLANG_VER="${ERLANG_VER:-28.2}"
|
||||||
|
REBAR_VER="${REBAR_VER:-3.27.0}"
|
||||||
|
|
||||||
|
ERL_BIN="${ASDF_DIR}/installs/erlang/${ERLANG_VER}/bin"
|
||||||
|
REBAR_BIN="${ASDF_DIR}/installs/rebar/${REBAR_VER}/bin"
|
||||||
|
|
||||||
|
if [ ! -x "${ERL_BIN}/erl" ]; then
|
||||||
|
echo "wsl-dev-env: нет ${ERL_BIN}/erl (asdf install erlang ${ERLANG_VER})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -x "${REBAR_BIN}/rebar3" ]; then
|
||||||
|
echo "wsl-dev-env: нет ${REBAR_BIN}/rebar3 (asdf install rebar ${REBAR_VER})" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Только Linux PATH — без /mnt/c/.../Erlang OTP и rebar3
|
||||||
|
export PATH="${ERL_BIN}:${REBAR_BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
@@ -318,7 +318,8 @@ ticket_to_json(Ticket) ->
|
|||||||
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
|
||||||
status => Ticket#ticket.status,
|
status => Ticket#ticket.status,
|
||||||
assigned_to => Ticket#ticket.assigned_to,
|
assigned_to => Ticket#ticket.assigned_to,
|
||||||
resolution_note => Ticket#ticket.resolution_note
|
resolution_note => Ticket#ticket.resolution_note,
|
||||||
|
closed_at => datetime_to_iso8601(Ticket#ticket.closed_at)
|
||||||
}.
|
}.
|
||||||
|
|
||||||
%% @doc Преобразует #calendar{} в JSON-карту.
|
%% @doc Преобразует #calendar{} в JSON-карту.
|
||||||
|
|||||||
@@ -50,15 +50,26 @@ get_permissions(_) -> [].
|
|||||||
%% -- работа с IP -----------------------------------------------------------
|
%% -- работа с IP -----------------------------------------------------------
|
||||||
|
|
||||||
%% @doc Возвращает IP-адрес клиента в виде бинарной строки (напр. <<"127.0.0.1">>).
|
%% @doc Возвращает IP-адрес клиента в виде бинарной строки (напр. <<"127.0.0.1">>).
|
||||||
%% Сохранена для обратной совместимости; внутренне вызывает ip_to_binary/1.
|
%% Сначала читает заголовки прокси, затем fallback на cowboy_req:peer/1.
|
||||||
-spec client_ip(cowboy_req:req()) -> binary().
|
-spec client_ip(cowboy_req:req()) -> binary().
|
||||||
client_ip(Req) ->
|
client_ip(Req) ->
|
||||||
ip_to_binary(cowboy_req:peer(Req)).
|
case forwarded_client_ip(Req) of
|
||||||
|
undefined -> ip_to_binary(cowboy_req:peer(Req));
|
||||||
|
Ip -> Ip
|
||||||
|
end.
|
||||||
|
|
||||||
%% @doc Преобразует IP-адрес из кортежа {A,B,C,D} в бинарную строку.
|
%% @doc Преобразует IP-адрес в бинарную строку.
|
||||||
-spec ip_to_binary({byte(), byte(), byte(), byte()}) -> binary().
|
-spec ip_to_binary(term()) -> binary().
|
||||||
ip_to_binary({A, B, C, D}) ->
|
ip_to_binary(IP) when is_tuple(IP) ->
|
||||||
list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D]));
|
case tuple_size(IP) of
|
||||||
|
4 -> list_to_binary(inet:ntoa(IP));
|
||||||
|
8 -> list_to_binary(inet:ntoa(IP));
|
||||||
|
_ -> <<"unknown">>
|
||||||
|
end;
|
||||||
|
ip_to_binary(IP) when is_binary(IP) ->
|
||||||
|
trim_ip_binary(IP);
|
||||||
|
ip_to_binary(IP) when is_list(IP) ->
|
||||||
|
list_to_binary(string:trim(IP));
|
||||||
ip_to_binary(_) ->
|
ip_to_binary(_) ->
|
||||||
<<"unknown">>.
|
<<"unknown">>.
|
||||||
|
|
||||||
@@ -97,8 +108,31 @@ log_admin_action(AdminId, Action, EntityType, EntityId, Reason, Req) ->
|
|||||||
{ok, Admin} ->
|
{ok, Admin} ->
|
||||||
Email = Admin#admin.email,
|
Email = Admin#admin.email,
|
||||||
Role = atom_to_binary(Admin#admin.role, utf8),
|
Role = atom_to_binary(Admin#admin.role, utf8),
|
||||||
Ip = ip_to_binary(cowboy_req:peer(Req)),
|
Ip = client_ip(Req),
|
||||||
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason),
|
core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason),
|
||||||
ok;
|
ok;
|
||||||
_ -> ok
|
_ -> ok
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
forwarded_client_ip(Req) ->
|
||||||
|
pick_forwarded_ip(Req, [<<"x-forwarded-for">>, <<"x-real-ip">>]).
|
||||||
|
|
||||||
|
|
||||||
|
pick_forwarded_ip(_Req, []) ->
|
||||||
|
undefined;
|
||||||
|
pick_forwarded_ip(Req, [Header | Rest]) ->
|
||||||
|
case cowboy_req:header(Header, Req) of
|
||||||
|
undefined -> pick_forwarded_ip(Req, Rest);
|
||||||
|
Value -> first_forwarded_ip(Value)
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
first_forwarded_ip(Value) ->
|
||||||
|
case binary:split(Value, <<",">>) of
|
||||||
|
[Head | _] -> trim_ip_binary(Head);
|
||||||
|
[] -> undefined
|
||||||
|
end.
|
||||||
|
|
||||||
|
|
||||||
|
trim_ip_binary(Ip) ->
|
||||||
|
list_to_binary(string:trim(binary_to_list(Ip))).
|
||||||
@@ -148,9 +148,60 @@ trunc_hours(Hours) ->
|
|||||||
round(Hours * 100) / 100.
|
round(Hours * 100) / 100.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Сбор активности администраторов (заглушка).
|
%%% @doc Сбор активности администраторов из записей аудита.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec collect_admin_activity() -> [map()].
|
-spec collect_admin_activity() -> [map()].
|
||||||
collect_admin_activity() ->
|
collect_admin_activity() ->
|
||||||
[].
|
Audits = core_admin_audit:list(),
|
||||||
|
Grouped = group_audit_by_admin(Audits),
|
||||||
|
Activity = maps:fold(fun build_admin_activity_entry/3, [], Grouped),
|
||||||
|
lists:sort(fun sort_by_actions_desc/2, Activity).
|
||||||
|
|
||||||
|
group_audit_by_admin(Audits) ->
|
||||||
|
lists:foldl(fun aggregate_audit_entry/2, #{}, Audits).
|
||||||
|
|
||||||
|
aggregate_audit_entry(#admin_audit{admin_id = AdminId, action = Action, timestamp = Ts}, Acc) ->
|
||||||
|
Entry = maps:get(AdminId, Acc, #{actions => 0, last_login => undefined}),
|
||||||
|
Actions = maps:get(actions, Entry) + 1,
|
||||||
|
LastLogin = case Action of
|
||||||
|
<<"login">> -> latest_datetime(maps:get(last_login, Entry), Ts);
|
||||||
|
_ -> maps:get(last_login, Entry)
|
||||||
|
end,
|
||||||
|
maps:put(AdminId, #{actions => Actions, last_login => LastLogin}, Acc).
|
||||||
|
|
||||||
|
build_admin_activity_entry(AdminId, #{actions := Actions, last_login := LastLogin}, Acc) ->
|
||||||
|
case core_admin:get_by_id(AdminId) of
|
||||||
|
{ok, Admin} ->
|
||||||
|
[#{
|
||||||
|
<<"admin_id">> => AdminId,
|
||||||
|
<<"email">> => Admin#admin.email,
|
||||||
|
<<"nickname">> => Admin#admin.nickname,
|
||||||
|
<<"role">> => atom_to_binary(Admin#admin.role, utf8),
|
||||||
|
<<"actions">> => Actions,
|
||||||
|
<<"last_login">> => format_datetime(maybe_last_login(LastLogin, Admin#admin.last_login))
|
||||||
|
} | Acc];
|
||||||
|
_ ->
|
||||||
|
Acc
|
||||||
|
end.
|
||||||
|
|
||||||
|
maybe_last_login(undefined, AdminLastLogin) -> AdminLastLogin;
|
||||||
|
maybe_last_login(LoginFromAudit, _) -> LoginFromAudit.
|
||||||
|
|
||||||
|
latest_datetime(undefined, Ts) -> Ts;
|
||||||
|
latest_datetime(Existing, Ts) ->
|
||||||
|
case calendar:datetime_to_gregorian_seconds(Ts) >=
|
||||||
|
calendar:datetime_to_gregorian_seconds(Existing) of
|
||||||
|
true -> Ts;
|
||||||
|
false -> Existing
|
||||||
|
end.
|
||||||
|
|
||||||
|
sort_by_actions_desc(#{<<"actions">> := A}, #{<<"actions">> := B}) ->
|
||||||
|
A >= B.
|
||||||
|
|
||||||
|
format_datetime(undefined) -> <<"">>;
|
||||||
|
format_datetime(Bin) when is_binary(Bin) -> Bin;
|
||||||
|
format_datetime({{Y, M, D}, {H, Mi, S}}) ->
|
||||||
|
iolist_to_binary(
|
||||||
|
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
|
||||||
|
[Y, M, D, H, Mi, S])).
|
||||||
@@ -26,7 +26,10 @@ report_error(ErrorMessage, Stacktrace, Context) ->
|
|||||||
mnesia:dirty_write(Updated),
|
mnesia:dirty_write(Updated),
|
||||||
{ok, Updated};
|
{ok, Updated};
|
||||||
[] ->
|
[] ->
|
||||||
|
ReporterId = maps:get(<<"reporter_id">>, Context,
|
||||||
|
maps:get(reporter_id, Context, <<"anonymous">>)),
|
||||||
Data = #{
|
Data = #{
|
||||||
|
<<"reporter_id">> => ReporterId,
|
||||||
<<"error_message">> => ErrorMessage,
|
<<"error_message">> => ErrorMessage,
|
||||||
<<"stacktrace">> => Stacktrace,
|
<<"stacktrace">> => Stacktrace,
|
||||||
<<"context">> => list_to_binary(io_lib:format("~p", [Context]))
|
<<"context">> => list_to_binary(io_lib:format("~p", [Context]))
|
||||||
@@ -75,6 +78,8 @@ list_tickets_by_status(AdminId, Status) ->
|
|||||||
end.
|
end.
|
||||||
|
|
||||||
%% Обновить статус тикета
|
%% Обновить статус тикета
|
||||||
|
update_status(AdminId, TicketId, closed) ->
|
||||||
|
close_ticket(AdminId, TicketId);
|
||||||
update_status(AdminId, TicketId, Status) ->
|
update_status(AdminId, TicketId, Status) ->
|
||||||
case admin_utils:is_admin(AdminId) of
|
case admin_utils:is_admin(AdminId) of
|
||||||
true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status});
|
true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status});
|
||||||
@@ -94,7 +99,8 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) ->
|
|||||||
true ->
|
true ->
|
||||||
core_ticket:update_ticket(TicketId, #{
|
core_ticket:update_ticket(TicketId, #{
|
||||||
<<"status">> => <<"resolved">>,
|
<<"status">> => <<"resolved">>,
|
||||||
<<"resolution_note">> => ResolutionNote
|
<<"resolution_note">> => ResolutionNote,
|
||||||
|
<<"closed_at">> => calendar:universal_time()
|
||||||
});
|
});
|
||||||
false -> {error, access_denied}
|
false -> {error, access_denied}
|
||||||
end.
|
end.
|
||||||
@@ -102,7 +108,7 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) ->
|
|||||||
%% Закрыть тикет
|
%% Закрыть тикет
|
||||||
close_ticket(AdminId, TicketId) ->
|
close_ticket(AdminId, TicketId) ->
|
||||||
case admin_utils:is_admin(AdminId) of
|
case admin_utils:is_admin(AdminId) of
|
||||||
true -> core_ticket:update_ticket(TicketId, #{<<"status">> => <<"closed">>});
|
true -> core_ticket:close(TicketId, AdminId);
|
||||||
false -> {error, access_denied}
|
false -> {error, access_denied}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ test() ->
|
|||||||
% ── Основные тесты ──
|
% ── Основные тесты ──
|
||||||
test_list_audit(SuperToken),
|
test_list_audit(SuperToken),
|
||||||
test_list_admin_forbidden(AdminToken),
|
test_list_admin_forbidden(AdminToken),
|
||||||
|
test_login_audit_ip(SuperToken),
|
||||||
|
|
||||||
% ── Фильтрация ──
|
% ── Фильтрация ──
|
||||||
test_filter_audit_by_admin_id(SuperToken, AdminId),
|
test_filter_audit_by_admin_id(SuperToken, AdminId),
|
||||||
@@ -85,6 +86,23 @@ test_list_admin_forbidden(Token) ->
|
|||||||
?assertMatch({ok, 403, _, _}, Resp),
|
?assertMatch({ok, 403, _, _}, Resp),
|
||||||
ct:pal(" OK: got 403").
|
ct:pal(" OK: got 403").
|
||||||
|
|
||||||
|
%% @doc Логин с X-Forwarded-For записывает IP клиента в аудит.
|
||||||
|
test_login_audit_ip(Token) ->
|
||||||
|
ct:pal(" TEST: Login audit stores X-Forwarded-For IP"),
|
||||||
|
Email = api_test_runner:admin_super_email(),
|
||||||
|
Password = api_test_runner:admin_super_password(),
|
||||||
|
Body = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
|
||||||
|
{ok, 200, _, _} = api_test_runner:admin_request(
|
||||||
|
post, <<"/v1/admin/login">>, <<>>, Body,
|
||||||
|
[{"X-Forwarded-For", "203.0.113.99"}]
|
||||||
|
),
|
||||||
|
Records = api_test_runner:admin_get(<<"/v1/admin/audit?action=login">>, Token),
|
||||||
|
Forwarded = [R || R <- Records, maps:get(<<"ip">>, R) =:= <<"203.0.113.99">>],
|
||||||
|
?assert(length(Forwarded) >= 1),
|
||||||
|
Login = hd(Forwarded),
|
||||||
|
?assertEqual(<<"203.0.113.99">>, maps:get(<<"ip">>, Login)),
|
||||||
|
ct:pal(" OK: ip=~s", [maps:get(<<"ip">>, Login)]).
|
||||||
|
|
||||||
%% ── Фильтрация ──
|
%% ── Фильтрация ──
|
||||||
|
|
||||||
%% @doc Фильтрация по admin_id.
|
%% @doc Фильтрация по admin_id.
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ test() ->
|
|||||||
|
|
||||||
test_stats_with_dates(SuperToken),
|
test_stats_with_dates(SuperToken),
|
||||||
test_node_metrics_history(SuperToken),
|
test_node_metrics_history(SuperToken),
|
||||||
|
test_avg_ticket_resolution_after_close(SuperToken, UserToken),
|
||||||
|
test_admin_activity(SuperToken),
|
||||||
|
|
||||||
% Детальная статистика
|
% Детальная статистика
|
||||||
test_user_stats(SuperToken),
|
test_user_stats(SuperToken),
|
||||||
@@ -116,6 +118,7 @@ test_stats_for_role(RoleName, Token, Strictness) ->
|
|||||||
?assert(maps:is_key(<<"tickets_by_day">>, Stats)),
|
?assert(maps:is_key(<<"tickets_by_day">>, Stats)),
|
||||||
?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)),
|
?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)),
|
||||||
?assert(maps:is_key(<<"pending_users_by_day">>, Stats)),
|
?assert(maps:is_key(<<"pending_users_by_day">>, Stats)),
|
||||||
|
?assert(maps:is_key(<<"avg_ticket_resolution_h">>, Stats)),
|
||||||
% Проверка, что они действительно списки
|
% Проверка, что они действительно списки
|
||||||
?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))),
|
?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))),
|
||||||
?assert(is_list(maps:get(<<"events_by_day">>, Stats))),
|
?assert(is_list(maps:get(<<"events_by_day">>, Stats))),
|
||||||
@@ -244,4 +247,35 @@ test_node_metrics_history(Token) ->
|
|||||||
?assert(maps:is_key(<<"memory_total">>, First)),
|
?assert(maps:is_key(<<"memory_total">>, First)),
|
||||||
?assert(maps:is_key(<<"memory_available">>, First)),
|
?assert(maps:is_key(<<"memory_available">>, First)),
|
||||||
?assert(maps:is_key(<<"cpu_utilization">>, First)),
|
?assert(maps:is_key(<<"cpu_utilization">>, First)),
|
||||||
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
|
ct:pal(" OK: ~p metrics received", [length(Metrics)]).
|
||||||
|
|
||||||
|
test_avg_ticket_resolution_after_close(Token, UserToken) ->
|
||||||
|
ct:pal(" TEST: avg_ticket_resolution_h after closing ticket"),
|
||||||
|
Ticket = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
||||||
|
#{<<"error_message">> => <<"Resolution metric">>, <<"stacktrace">> => <<"trace">>}),
|
||||||
|
#{<<"id">> := TicketId} = Ticket,
|
||||||
|
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
|
||||||
|
timer:sleep(20000),
|
||||||
|
Closed = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"closed">>}),
|
||||||
|
?assertEqual(<<"closed">>, maps:get(<<"status">>, Closed)),
|
||||||
|
?assertNotEqual(<<"1970-01-01T00:00:00Z">>, maps:get(<<"closed_at">>, Closed)),
|
||||||
|
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
|
||||||
|
Avg = maps:get(<<"avg_ticket_resolution_h">>, Stats),
|
||||||
|
?assert(is_number(Avg)),
|
||||||
|
?assert(Avg > 0.0),
|
||||||
|
ct:pal(" OK: avg_ticket_resolution_h=~p", [Avg]).
|
||||||
|
|
||||||
|
test_admin_activity(Token) ->
|
||||||
|
ct:pal(" TEST: admin_activity in dashboard stats"),
|
||||||
|
Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token),
|
||||||
|
Activity = maps:get(<<"admin_activity">>, Stats),
|
||||||
|
?assert(is_list(Activity)),
|
||||||
|
?assert(length(Activity) >= 1),
|
||||||
|
First = hd(Activity),
|
||||||
|
?assert(maps:is_key(<<"admin_id">>, First)),
|
||||||
|
?assert(maps:is_key(<<"email">>, First)),
|
||||||
|
?assert(maps:is_key(<<"role">>, First)),
|
||||||
|
?assert(maps:is_key(<<"actions">>, First)),
|
||||||
|
?assert(maps:is_key(<<"last_login">>, First)),
|
||||||
|
?assert(maps:get(<<"actions">>, First) >= 1),
|
||||||
|
ct:pal(" OK: ~p admin activity rows", [length(Activity)]).
|
||||||
@@ -23,10 +23,13 @@
|
|||||||
register_and_login/2,
|
register_and_login/2,
|
||||||
create_calendar/2,
|
create_calendar/2,
|
||||||
create_event/3
|
create_event/3
|
||||||
, get_admin_refresh_token/0]).
|
, get_admin_refresh_token/0
|
||||||
|
, admin_super_email/0
|
||||||
|
, admin_super_password/0]).
|
||||||
-export([
|
-export([
|
||||||
admin_request/3,
|
admin_request/3,
|
||||||
admin_request/4,
|
admin_request/4,
|
||||||
|
admin_request/5,
|
||||||
client_request/3,
|
client_request/3,
|
||||||
client_request/4
|
client_request/4
|
||||||
]).
|
]).
|
||||||
@@ -175,7 +178,12 @@ admin_request(Method, Path, Token) ->
|
|||||||
|
|
||||||
-spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
-spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
admin_request(Method, Path, Token, Body) ->
|
admin_request(Method, Path, Token, Body) ->
|
||||||
request(get_admin_url(), Method, Path, Token, Body, "ADMIN").
|
admin_request(Method, Path, Token, Body, []).
|
||||||
|
|
||||||
|
-spec admin_request(atom(), binary(), binary(), binary(), [{string(), string()}]) ->
|
||||||
|
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
|
admin_request(Method, Path, Token, Body, ExtraHeaders) ->
|
||||||
|
request(get_admin_url(), Method, Path, Token, Body, "ADMIN", ExtraHeaders).
|
||||||
|
|
||||||
-spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
-spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
client_request(Method, Path, Token) ->
|
client_request(Method, Path, Token) ->
|
||||||
@@ -183,7 +191,7 @@ client_request(Method, Path, Token) ->
|
|||||||
|
|
||||||
-spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
-spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
client_request(Method, Path, Token, Body) ->
|
client_request(Method, Path, Token, Body) ->
|
||||||
request(get_base_url(), Method, Path, Token, Body, "CLIENT").
|
request(get_base_url(), Method, Path, Token, Body, "CLIENT", []).
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Внутренняя реализация HTTP-запроса
|
%%% Внутренняя реализация HTTP-запроса
|
||||||
@@ -191,12 +199,17 @@ client_request(Method, Path, Token, Body) ->
|
|||||||
|
|
||||||
-spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
-spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
request(BaseUrl, Method, Path, Token, Body, Prefix) ->
|
request(BaseUrl, Method, Path, Token, Body, Prefix) ->
|
||||||
|
request(BaseUrl, Method, Path, Token, Body, Prefix, []).
|
||||||
|
|
||||||
|
-spec request(string(), atom(), binary(), binary(), binary(), string(), [{string(), string()}]) ->
|
||||||
|
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
|
||||||
|
request(BaseUrl, Method, Path, Token, Body, Prefix, ExtraHeaders) ->
|
||||||
URL = BaseUrl ++ binary_to_list(Path),
|
URL = BaseUrl ++ binary_to_list(Path),
|
||||||
Headers0 = [],
|
AuthHeaders = case Token of
|
||||||
Headers = case Token of
|
<<>> -> [];
|
||||||
<<>> -> Headers0; % пустой токен – не добавляем Authorization
|
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
|
||||||
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
|
end,
|
||||||
end,
|
Headers = ExtraHeaders ++ AuthHeaders,
|
||||||
ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]),
|
ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]),
|
||||||
RequestArg = case Method of
|
RequestArg = case Method of
|
||||||
get -> {URL, Headers};
|
get -> {URL, Headers};
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-module(admin_utils_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
client_ip_test_() ->
|
||||||
|
{setup,
|
||||||
|
fun() -> ok = meck:new(cowboy_req, [non_strict]) end,
|
||||||
|
fun(_) -> meck:unload(cowboy_req) end,
|
||||||
|
[
|
||||||
|
{"Uses X-Forwarded-For when present", fun test_forwarded_for/0},
|
||||||
|
{"Falls back to X-Real-IP", fun test_real_ip/0},
|
||||||
|
{"Falls back to peer address", fun test_peer_fallback/0},
|
||||||
|
{"ip_to_binary handles IPv4 tuple", fun test_ipv4_tuple/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
test_forwarded_for() ->
|
||||||
|
ok = meck:expect(cowboy_req, header, fun
|
||||||
|
(<<"x-forwarded-for">>, _) -> <<"203.0.113.10, 10.0.0.1">>;
|
||||||
|
(_, _) -> undefined
|
||||||
|
end),
|
||||||
|
ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end),
|
||||||
|
?assertEqual(<<"203.0.113.10">>, admin_utils:client_ip(req)).
|
||||||
|
|
||||||
|
test_real_ip() ->
|
||||||
|
ok = meck:expect(cowboy_req, header, fun
|
||||||
|
(<<"x-real-ip">>, _) -> <<"198.51.100.5">>;
|
||||||
|
(_, _) -> undefined
|
||||||
|
end),
|
||||||
|
ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end),
|
||||||
|
?assertEqual(<<"198.51.100.5">>, admin_utils:client_ip(req)).
|
||||||
|
|
||||||
|
test_peer_fallback() ->
|
||||||
|
ok = meck:expect(cowboy_req, header, fun(_, _) -> undefined end),
|
||||||
|
ok = meck:expect(cowboy_req, peer, fun(_) -> {127, 0, 0, 1} end),
|
||||||
|
?assertEqual(<<"127.0.0.1">>, admin_utils:client_ip(req)).
|
||||||
|
|
||||||
|
test_ipv4_tuple() ->
|
||||||
|
?assertEqual(<<"192.168.1.1">>, admin_utils:ip_to_binary({192, 168, 1, 1})).
|
||||||
@@ -15,20 +15,24 @@ setup() ->
|
|||||||
{attributes, record_info(fields, user)}, {ram_copies, [node()]}]),
|
{attributes, record_info(fields, user)}, {ram_copies, [node()]}]),
|
||||||
{atomic, ok} = mnesia:create_table(ticket, [
|
{atomic, ok} = mnesia:create_table(ticket, [
|
||||||
{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]),
|
{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]),
|
||||||
% Создаём админа и обычного пользователя
|
{atomic, ok} = mnesia:create_table(admin, [
|
||||||
Admin = #user{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>,
|
{attributes, record_info(fields, admin)}, {ram_copies, [node()]}]),
|
||||||
role = admin, status = active,
|
Now = calendar:universal_time(),
|
||||||
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
|
Admin = #admin{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>,
|
||||||
|
role = admin, status = active, nickname = <<>>, avatar_url = default,
|
||||||
|
timezone = <<"UTC">>, language = <<"ru">>, phone = <<>>,
|
||||||
|
preferences = #{}, last_login = Now, created_at = Now, updated_at = Now},
|
||||||
User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>,
|
User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>,
|
||||||
role = user, status = active,
|
role = user, status = active,
|
||||||
created_at = calendar:universal_time(), updated_at = calendar:universal_time()},
|
created_at = Now, updated_at = Now},
|
||||||
mnesia:dirty_write(Admin),
|
mnesia:dirty_write(Admin),
|
||||||
mnesia:dirty_write(User),
|
mnesia:dirty_write(User),
|
||||||
ok.
|
ok.
|
||||||
|
|
||||||
cleanup(_) ->
|
cleanup(_) ->
|
||||||
mnesia:delete_table(user),
|
catch mnesia:delete_table(admin),
|
||||||
mnesia:delete_table(ticket),
|
catch mnesia:delete_table(user),
|
||||||
|
catch mnesia:delete_table(ticket),
|
||||||
mnesia:stop().
|
mnesia:stop().
|
||||||
|
|
||||||
%% ----------------------------------------------------------------
|
%% ----------------------------------------------------------------
|
||||||
@@ -49,7 +53,7 @@ logic_ticket_test_() ->
|
|||||||
|
|
||||||
%% --- Вспомогательная функция для создания тикета ---
|
%% --- Вспомогательная функция для создания тикета ---
|
||||||
report(ErrorMsg) ->
|
report(ErrorMsg) ->
|
||||||
logic_ticket:report_error(ErrorMsg, <<"stack">>, #{}).
|
logic_ticket:report_error(ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}).
|
||||||
|
|
||||||
%% --- Тесты ---
|
%% --- Тесты ---
|
||||||
|
|
||||||
@@ -87,13 +91,14 @@ test_assign_ticket() ->
|
|||||||
test_resolve_ticket() ->
|
test_resolve_ticket() ->
|
||||||
{ok, Ticket} = report(<<"E4">>),
|
{ok, Ticket} = report(<<"E4">>),
|
||||||
{ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>),
|
{ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>),
|
||||||
?assertEqual(closed, Updated#ticket.status),
|
?assertEqual(resolved, Updated#ticket.status),
|
||||||
?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note).
|
?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note).
|
||||||
|
|
||||||
test_close_ticket() ->
|
test_close_ticket() ->
|
||||||
{ok, Ticket} = report(<<"E5">>),
|
{ok, Ticket} = report(<<"E5">>),
|
||||||
{ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id),
|
{ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id),
|
||||||
?assertEqual(closed, Updated#ticket.status).
|
?assertEqual(closed, Updated#ticket.status),
|
||||||
|
?assertNotEqual({{1970, 1, 1}, {0, 0, 0}}, Updated#ticket.closed_at).
|
||||||
|
|
||||||
test_get_statistics() ->
|
test_get_statistics() ->
|
||||||
{ok, _} = report(<<"E6">>),
|
{ok, _} = report(<<"E6">>),
|
||||||
|
|||||||
Reference in New Issue
Block a user