-module(admin_handler_login_tests). -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). -define(TABLES, [admin, admin_audit, auth_session, ticket]). -define(EMAIL, <<"admin@test.local">>). -define(PASSWORD, <<"SecretPass1!">>). setup() -> eh_test_support:start_mnesia(), eh_test_support:ensure_tables(?TABLES), eh_test_support:ensure_jwt(), catch application:ensure_all_started(argon2), catch ets:new(eventhub_counters, [named_table, public, set, {write_concurrency, true}]), ok. cleanup(_) -> eh_test_support:unload_cowboy(), eh_test_support:delete_tables(?TABLES), eh_test_support:stop_mnesia(), ok. admin_login_test_() -> {foreach, fun setup/0, fun cleanup/1, [ {"POST login – success", fun test_login_ok/0}, {"POST login – bad credentials", fun test_login_bad_credentials/0}, {"POST login – insufficient permissions", fun test_login_forbidden_role/0}, {"POST login – missing fields", fun test_login_missing_fields/0}, {"POST login – missing body", fun test_login_missing_body/0}, {"POST login – invalid JSON", fun test_login_invalid_json/0}, {"GET login – method not allowed", fun test_login_wrong_method/0} ]}. %% argon2 is a Rust NIF; rebar hooks only build it on unix. Skip hash-dependent %% cases when the NIF is unavailable (common on Windows without cargo). argon2_ready() -> case catch argon2:hash(<<"probe">>) of {ok, _} -> true; _ -> false end. require_argon2(Fun) -> case argon2_ready() of true -> Fun(); false -> {skip, "argon2 NIF not available"} end. test_login_ok() -> require_argon2(fun() -> {ok, Admin} = core_admin:create(?EMAIL, ?PASSWORD, admin), {Status, _, Body} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, body => jsx:encode(#{<<"email">> => ?EMAIL, <<"password">> => ?PASSWORD}) }), ?assertEqual(200, Status), Resp = jsx:decode(Body, [return_maps]), ?assert(is_map_key(<<"token">>, Resp)), ?assert(is_map_key(<<"refresh_token">>, Resp)), User = maps:get(<<"user">>, Resp), ?assertEqual(Admin#admin.id, maps:get(<<"id">>, User)), ?assertEqual(?EMAIL, maps:get(<<"email">>, User)), ?assertEqual(<<"admin">>, maps:get(<<"role">>, User)), Audits = core_admin_audit:list(), ?assert(length(Audits) >= 1) end). test_login_bad_credentials() -> %% Unknown email does not need password verification. {Status, _, Body} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, body => jsx:encode(#{<<"email">> => <<"nobody@test.local">>, <<"password">> => <<"x">>}) }), ?assertEqual(401, Status), #{<<"error">> := <<"invalid_credentials">>} = jsx:decode(Body, [return_maps]). test_login_forbidden_role() -> require_argon2(fun() -> {ok, Hash} = logic_auth:hash_password(?PASSWORD), eh_test_support:seed_admin(#{ id => <<"adm_guest">>, email => <<"guest@test.local">>, password_hash => Hash, role => guest }), {Status, _, Body} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, body => jsx:encode(#{ <<"email">> => <<"guest@test.local">>, <<"password">> => ?PASSWORD }) }), ?assertEqual(403, Status), #{<<"error">> := <<"insufficient_permissions">>} = jsx:decode(Body, [return_maps]) end). test_login_missing_fields() -> {Status, _, _} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, body => jsx:encode(#{<<"email">> => ?EMAIL}) }), ?assertEqual(400, Status). test_login_missing_body() -> {Status, _, _} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, has_body => false }), ?assertEqual(400, Status). test_login_invalid_json() -> {Status, _, _} = eh_test_support:call(admin_handler_login, #{ method => <<"POST">>, path => <<"/v1/admin/login">>, auth => none, body => <<"not-json">> }), ?assertEqual(400, Status). test_login_wrong_method() -> {Status, _, _} = eh_test_support:call(admin_handler_login, #{ method => <<"GET">>, path => <<"/v1/admin/login">>, auth => none }), ?assertEqual(405, Status).