diff --git a/src/rtdev.erl b/src/rtdev.erl index a7c9294..6545b4d 100644 --- a/src/rtdev.erl +++ b/src/rtdev.erl @@ -884,7 +884,7 @@ stop_node_fun(#stop_node{ ?LOG_ERROR("~0p", [Error]), Error end, - ?LOG_INFO("Stopped node ~0p, stop status: ~s.", [Node, Status]) + ?LOG_INFO("Stopped node ~0p, stop status: ~p.", [Node, Status]) end. %% @hidden Kill any Erlang processes under the specified DevPath. diff --git a/tests/admin_api_http_tests.erl b/tests/admin_api_http_tests.erl new file mode 100644 index 0000000..3be0f2d --- /dev/null +++ b/tests/admin_api_http_tests.erl @@ -0,0 +1,959 @@ +%% ------------------------------------------------------------------- +%% +%% Copyright (c) 2026 TI Tokyo. +%% +%% This file is provided to you under the Apache License, +%% Version 2.0 (the "License"); you may not use this file +%% except in compliance with the License. You may obtain +%% a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, +%% software distributed under the License is distributed on an +%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +%% KIND, either express or implied. See the License for the +%% specific language governing permissions and limitations +%% under the License. +%% +%% ------------------------------------------------------------------- +-module(admin_api_http_tests). +-behavior(riak_test). + +-export([confirm/0]). + +-include_lib("kernel/include/logger.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +-define(ADMIN_USERNAME, "admin"). +-define(ADMIN_PASSWD, "damin"). + +confirm() -> + CertDir = rt_config:get(rt_scratch_dir) ++ "/http_certs", + make_certs:rootCA(CertDir, "rootCA"), + make_certs:endusers(CertDir, "rootCA", ["site1.basho.com"]), + + Conf1 = + [{riak_core, [{ssl, + [{certfile, + filename:join( + [CertDir, "site1.basho.com/cert.pem"]) + }, + {keyfile, + filename:join( + [CertDir, "site1.basho.com/key.pem"]) + }, + {cacertfile, + filename:join( + [CertDir, "site1.basho.com/cacerts.pem"]) + } + ] + } + ] + }, + {riak_kv, [{anti_entropy, {off, []}}, + {tictacaae_active, active} + ] + }, + {riak_admin_api, [{admin_api_enabled, true}, + {sec_group,[{superuser, true}, + {admin, true}, + {monitoring, true}]} + ] + } + ], + [Node1, _Node2] = Nodes = rt:build_cluster([Conf1, []]), + + {ok, _} = rt:admin(Node1, ["admin-api", "reset"]), + + US1 = filename:join([rt_config:get(rt_scratch_dir), "userspec-1"]), + file:write_file(US1, new_admin_blob()), + {ok, _} = rt:admin(Node1, ["admin-api", "add-user", US1]), + + {ok, [{IP, Port}]} = + erpc:call(Node1, application, get_env, [riak_admin_api, https]), + + application:ensure_all_started([ssl]), + + C = {IP, Port}, + general_tests(C), + + permissions_class_switch_tests(C, Node1), + group_permissions_tests(C, Nodes), + + node_config_getset_tests(C, Nodes), + + cluster_observer_tests(C, Nodes), + cluster_admin_tests(C, Nodes), + + security_user_tests(C), + security_group_crud_tests(C), + security_user_add_remove_group_tests(C), + security_user_add_remove_permission_tests(C), + + vnode_tests(C, Nodes), + tictacaae_tests(C, Nodes), + + pass. + + +general_tests(C) -> + ?LOG_INFO("* general_tests"), + assert_req( + C, {<<"SecurityListPermissions">>, #{}}, + {"200", [<<"cluster_observer">>, <<"cluster_admin">>, <<"security">>]}), + + assert_req( + C, {<<"SummonClaude">>, #{}}, + {"400", <<"Invalid request action">>}), + + ok. + + +node_config_getset_tests(C, [Node1|_]) -> + ?LOG_INFO("* node_config_getset_tests"), + assert_req( + C, {<<"NodeGetAppEnv">>, + #{<<"node">> => atom_to_binary(Node1)}}, + {"200", + fun(A) -> + case erl_parse:parse_term( + element(2, erl_scan:string(binary_to_list(A) ++ "."))) of + {ok, EE} when is_list(EE) -> + K = proplists:get_value(riak_admin_api, EE), + ?assert(K /= undefined), + ok; + _ -> + ?assert(false) + end + end}), + + assert_req( + C, {<<"NodePutAppEnv">>, #{<<"node">> => atom_to_binary(Node1), + <<"config">> => <<"[{riak_admin_api, [{who, me}]}]">>}}, + {"200", <<"ok">>}), + + ?assertEqual({ok, me}, erpc:call(Node1, application, get_env, [riak_admin_api, who])), + + assert_req( + C, {<<"NodeGetAdvancedConfig">>, #{<<"node">> => atom_to_binary(Node1)}}, + {"200", + fun(A) -> + persistent_term:put(advanced_config_on_Node1, A), + case erl_parse:parse_term( + element(2, erl_scan:string(binary_to_list(A) ++ "."))) of + {ok, EE} when is_list(EE) -> + K = proplists:get_value(riak_admin_api, EE), + ?assert(K /= undefined), + ok; + _ -> + ?assert(false) + end + end}), + + AdvancedConfigOnNode1 = persistent_term:get(advanced_config_on_Node1), + NewAdvConf = binary:replace(AdvancedConfigOnNode1, <<"[">>, <<"[{riak_ts, [{is_alive, yes}]},">>), + assert_req( + C, {<<"NodePutAdvancedConfig">>, #{<<"node">> => atom_to_binary(Node1), + <<"config">> => NewAdvConf}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"NodeGetAdvancedConfig">>, #{<<"node">> => atom_to_binary(Node1)}}, + {"200", + fun(A) -> + case erl_parse:parse_term( + element(2, erl_scan:string(binary_to_list(A) ++ "."))) of + {ok, EE} when is_list(EE) -> + K = proplists:get_value(riak_ts, EE), + ?assert(K /= undefined), + ok; + _ -> + ?assert(false) + end + end}), + + ok. + + +cluster_observer_tests(C, [Node1|_]) -> + ?LOG_INFO("* cluster_observer_tests"), + assert_req( + C, {<<"ClusterGetStatus">>, + #{<<"node">> => atom_to_binary(Node1)}}, + {"200", fun is_original_cluster/1}), + ok. + +cluster_admin_tests(C, Nodes) -> + ?LOG_INFO("* cluster_admin_tests"), + + assert_req( + C, {<<"ClusterStageLeave">>, #{<<"node">> => <<"dev2@127.0.0.1">>}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"ClusterPlan">>, #{}}, + {"200", + fun(#{<<"actions">> := [#{<<"action">> := <<"leave">>, + <<"node">> := <<"dev2@127.0.0.1">>}]}) -> ok; + (_) -> ?assert(false) end}), + + assert_req( + C, {<<"ClusterCommitPlan">>, #{}}, + {"200", <<"ok">>}), + + [Node1, Node2] = Nodes, + rt:wait_until_ready(Node1), + rt:wait_until_no_pending_changes([Node1]), + %% there would be transfers still ongoing + + assert_req( + C, {<<"ClusterGetStatus">>, + #{<<"node">> => atom_to_binary(Node1)}}, + {"200", fun is_original_cluster_with_one_node_leaving/1}), + + rt:wait_until_unpingable(Node2), + rt:start(Node2), + rt:wait_until_ready(Node2), + patient_assert_req( + C, {<<"ClusterStageJoin">>, #{<<"node">> => <<"dev2@127.0.0.1">>}}, + {"200", <<"ok">>}), + + patient_assert_req( + C, {<<"ClusterPlan">>, #{}}, + {"200", + fun(#{<<"actions">> := [#{<<"action">> := <<"join">>, + <<"node">> := <<"dev1@127.0.0.1">>}]}) -> ok; + (_) -> not_ok end}), + + assert_req( + C, {<<"ClusterCommitPlan">>, #{}}, + {"200", <<"ok">>}), + + rt:wait_until_ready(Node1), + rt:wait_until_ready(Node2), + rt:wait_until_no_pending_changes(Nodes), + + assert_req( + C, {<<"ClusterGetStatus">>, + #{<<"node">> => atom_to_binary(Node1)}}, + {"200", fun is_original_cluster/1}), + + ok. + + +is_original_cluster( + #{<<"current_cluster">> := + [ + #{ + <<"is_me">> := true, + <<"low_mem">> := LowMem1, + <<"mem_erlang">> := MemErlang1, + <<"mem_total">> := MemTotal1, + <<"mem_used">> := MemUsed1, + <<"name">> := <<"dev1@127.0.0.1">>, + <<"pending_pct">> := PendingPct1, + <<"reachable">> := true, + <<"replacement">> := null, + <<"ring_pct">> := RingPct1, + <<"services">> := [<<"riak_kv">>], + <<"staged_action">> := null, + <<"status">> := <<"valid">>, + <<"system_info">> := + #{ + <<"nodename">> := <<"dev1@127.0.0.1">>, + <<"riak_version">> := RiakVersion1, + <<"system_version">> := SystemVersion1, + <<"uptime">> := Uptime1, + <<"uptime_str">> := UptimeStr1 + } + }, + #{ + <<"is_me">> := false, + <<"low_mem">> := LowMem2, + <<"mem_erlang">> := MemErlang2, + <<"mem_total">> := MemTotal2, + <<"mem_used">> := MemUsed2, + <<"name">> := <<"dev2@127.0.0.1">>, + <<"pending_pct">> := PendingPct2, + <<"reachable">> := true, + <<"replacement">> := null, + <<"ring_pct">> := RingPct2, + <<"services">> := [<<"riak_kv">>], + <<"staged_action">> := null, + <<"status">> := <<"valid">>, + <<"system_info">> := + #{ + <<"nodename">> := <<"dev2@127.0.0.1">>, + <<"riak_version">> := RiakVersion2, + <<"system_version">> := SystemVersion2, + <<"uptime">> := Uptime2, + <<"uptime_str">> := UptimeStr2 + } + } + ], + <<"down_nodes">> := [], + <<"final_cluster">> := [], + <<"staged_changes">> := [] + }) when is_boolean(LowMem1), + is_boolean(LowMem2), + is_integer(MemErlang1), + is_integer(MemErlang2), + is_integer(MemTotal1), + is_integer(MemTotal2), + is_integer(MemUsed1), + is_integer(MemUsed2), + is_number(PendingPct1), + is_number(PendingPct2), + RingPct1 + RingPct2 == 1, + is_binary(RiakVersion1), + RiakVersion1 == RiakVersion2, + is_binary(SystemVersion1), + SystemVersion1 == SystemVersion2, + is_integer(Uptime1), + is_binary(UptimeStr1), + is_integer(Uptime2), + is_binary(UptimeStr2) -> + ok; +is_original_cluster(_) -> + ?assert(false). + +is_original_cluster_with_one_node_leaving( + #{<<"current_cluster">> := + [ + #{ + <<"name">> := <<"dev1@127.0.0.1">>, + <<"staged_action">> := null, + <<"status">> := <<"valid">> + }, + #{ + <<"name">> := <<"dev2@127.0.0.1">>, + <<"status">> := <<"leaving">> + } + ], + <<"down_nodes">> := [], + <<"final_cluster">> := [], + <<"staged_changes">> := [] + }) -> ok; +is_original_cluster_with_one_node_leaving(_) -> + ?assert(false). + + + +vnode_tests(C, [Node1|_]) -> + ?LOG_INFO("* vnode_tests"), + assert_req( + C, {<<"VnodeGetStatus">>, #{<<"node">> => atom_to_binary(Node1), + <<"preflists">> => <<"all">>}}, + {"200", + fun([#{<<"backend_status">> := #{}, + <<"counter">> := Counter, + <<"counter_lease">> := CounterLease, + <<"counter_lease_size">> := CounterLeaseTime, + <<"counter_leasing">> := CounterLeasing, + <<"idx">> := Idx, + <<"vnodeid">> := Vnodeid + }|_]) when is_integer(Counter), + is_integer(CounterLease), + is_integer(CounterLeaseTime), + is_boolean(CounterLeasing), + is_binary(Idx), + is_binary(Vnodeid) -> true; + (_) -> ?assert(false) + end}), + ok. + +tictacaae_tests(C, [Node1|_]) -> + ?LOG_INFO("* tictacaae_tests"), + assert_req( + C, {<<"TictacaaeGetStatus">>, #{<<"node">> => atom_to_binary(Node1)}}, + {"200", + fun([#{<<"controller_pid">> := ControllerPid, + <<"last_rebuild">> := <<"never">>, + <<"next_rebuild">> := NextRebuild, + <<"partition">> := Partition, + <<"status">> := Status, + <<"total_dirty_segments">> := 0 + }|_]) when is_binary(ControllerPid), + is_binary(NextRebuild), + is_binary(Partition), + is_binary(Status) -> true; + (_) -> ?assert(false) + end}), + ok. + + +-define(USER1, <<"Soren">>). +-define(USER1AUTH, #{<<"method">> => <<"password">>, + <<"password">> => <<"Password">>}). +security_user_tests(C) -> + ?LOG_INFO("* security_user_tests"), + + %% there's only one admin, created above via CLI + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun([ + #{<<"auth_method">> := <<"password">>, + <<"created">> := Created, + <<"expires">> := <<"never">>, + <<"groups">> := [], + <<"modified">> := Modified, + <<"name">> := <<"admin">>, + <<"permissions">> := [<<"cluster_observer">>, + <<"cluster_admin">>, + <<"security">>] + } + ]) when Created == Modified -> + ok; + (_) -> + ?assert(false) + end}), + + assert_req( + C, {<<"SecurityCreateUser">>, + #{<<"name">> => ?USER1, + <<"auth_details">> => ?USER1AUTH + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityCreateUser">>, + #{<<"name">> => ?USER1, + <<"auth_details">> => ?USER1AUTH + } + }, + {"412", <<"User or group already exists">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun([ + #{<<"name">> := <<"admin">> + }, + #{<<"auth_method">> := <<"password">>, + <<"created">> := Created, + <<"expires">> := <<"never">>, + <<"groups">> := [], + <<"modified">> := Modified, + <<"name">> := ?USER1, + <<"permissions">> := [] + } + ]) when Created == Modified -> + ok; + (_) -> + ?assert(false) + end}), + + NewExpires = os:system_time(millisecond) + 10_000, + assert_req( + C, {<<"SecuritySetUserExpiry">>, + #{<<"name">> => ?USER1, + <<"expires">> => NewExpires + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) when is_list(UU) -> + [#{<<"expires">> := Expires, + <<"created">> := Created, + <<"modified">> := Modified}] = + lists:filter(fun(#{<<"name">> := A}) -> A == ?USER1 end, UU), + case (calendar:rfc3339_to_system_time( + binary_to_list(Expires), [{unit, millisecond}]) + == NewExpires) and + (Created < Modified) of + true -> ok; + false -> ?assert(false) + end; + (_) -> ?assert(false) + end}), + + assert_req( + C, {<<"SecuritySetUserExpiry">>, + #{<<"name">> => ?USER1, + <<"expires">> => <<"never">> + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun([_, #{<<"expires">> := <<"never">>}]) -> ok; + (_) -> ?assert(false) + end}), + + assert_req( + C, {<<"SecuritySetUserExpiry">>, + #{<<"name">> => <<"NotSoren">>, + <<"expires">> => <<"never">> + } + }, + {"404", <<"No such user or group">>}), + + assert_req( + C, {<<"SecuritySetUserExpiry">>, + #{<<"name">> => <<"NotSoren">>, + <<"expires">> => NewExpires - 10_000 + } + }, + {"400", <<"Invalid parameter">>}), + + + assert_req( + C, {<<"SecurityDeleteUser">>, + #{<<"name">> => ?USER1 + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun([#{<<"name">> := <<"admin">>}]) -> + ok; + (_) -> + ?assert(false) + end}), + + ok. + +security_group_crud_tests(C) -> + ?LOG_INFO("* security_group_crud_tests"), + + assert_req( + C, {<<"SecurityListGroups">>, #{}}, + {"200", []}), + + assert_req( + C, {<<"SecurityCreateGroup">>, + #{<<"name">> => <<"Serena">>} + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityCreateGroup">>, + #{<<"name">> => <<"Serena">>} + }, + {"412", <<"User or group already exists">>}), + + assert_req( + C, {<<"SecurityListGroups">>, #{}}, + {"200", + fun([ + #{<<"created">> := Created, + <<"modified">> := Modified, + <<"name">> := <<"Serena">> + } + ]) when is_binary(Created), + Created == Modified -> + ok; + (_) -> + ?assert(false) + end}), + + assert_req( + C, {<<"SecurityDeleteGroup">>, + #{<<"name">> => <<"NotSerena">>} + }, + {"404", <<"No such user or group">>}), + + assert_req( + C, {<<"SecurityDeleteGroup">>, + #{<<"name">> => <<"Serena">>} + }, + {"200", <<"ok">>}), + + ok. + + +security_user_add_remove_group_tests(C) -> + ?LOG_INFO("* security_user_add_remove_group_tests"), + + assert_req( + C, {<<"SecurityCreateUser">>, + #{<<"name">> => ?USER1, + <<"auth_details">> => ?USER1AUTH + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityCreateGroup">>, + #{<<"name">> => <<"Serena">>} + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityAddUserGroups">>, #{<<"user">> => ?USER1, + <<"groups">> => [<<"Serena">>]}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityAddUserGroups">>, #{<<"user">> => ?USER1, + <<"groups">> => [<<"NotSerena">>]}}, + {"412", <<"No such user or group">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"groups">> := [<<"Serena">>], + <<"name">> := ?USER1 + }) -> true; + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + %% adding existing group, idempotent + assert_req( + C, {<<"SecurityAddUserGroups">>, #{<<"user">> => ?USER1, + <<"groups">> => [<<"Serena">>]}}, + {"200", <<"ok">>}), + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"groups">> := [<<"Serena">>], + <<"name">> := ?USER1 + }) -> true; + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + + assert_req( + C, {<<"SecurityDeleteUserGroups">>, #{<<"user">> => ?USER1, + <<"groups">> => [<<"Serena">>]}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"groups">> := [], + <<"name">> := ?USER1 + }) -> true; + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + %% idempotent delete + assert_req( + C, {<<"SecurityDeleteUserGroups">>, #{<<"user">> => ?USER1, + <<"groups">> => [<<"Serena">>]}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"groups">> := [], + <<"name">> := ?USER1 + }) -> true; + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + assert_req( + C, {<<"SecurityDeleteUser">>, + #{<<"name">> => ?USER1 + } + }, + {"200", <<"ok">>}), + + ok. + + +security_user_add_remove_permission_tests(C) -> + ?LOG_INFO("* security_user_add_remove_permission_tests"), + + assert_req( + C, {<<"SecurityCreateUser">>, + #{<<"name">> => ?USER1, + <<"auth_details">> => ?USER1AUTH + } + }, + {"200", <<"ok">>}), + + NewPerms = [<<"security">>, <<"cluster_observer">>], + assert_req( + C, {<<"SecurityAddUserPermissions">>, #{<<"user">> => ?USER1, + <<"permissions">> => NewPerms}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityAddUserPermissions">>, #{<<"user">> => ?USER1, + <<"permissions">> => [<<"idkfa">>]}}, + {"400", <<"Invalid parameter">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"permissions">> := PP, + <<"name">> := ?USER1 + }) -> lists:sort(PP) == lists:sort(NewPerms); + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + %% adding existing perms is idempotent + assert_req( + C, {<<"SecurityAddUserPermissions">>, #{<<"user">> => ?USER1, + <<"permissions">> => NewPerms}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"permissions">> := PP, + <<"name">> := ?USER1 + }) -> lists:sort(PP) == lists:sort(NewPerms); + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + + PermsToDelete = [<<"security">>], + RemainingPerms = NewPerms -- PermsToDelete, + assert_req( + C, {<<"SecurityDeleteUserPermissions">>, #{<<"user">> => ?USER1, + <<"permissions">> => PermsToDelete}}, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"200", + fun(UU) -> + case lists:any( + fun(#{<<"permissions">> := PP, + <<"name">> := ?USER1 + }) when PP == RemainingPerms -> true; + (_) -> false + end, UU) of + true -> + ok; + (_) -> + ?assert(false) + end + end}), + + assert_req( + C, {<<"SecurityDeleteUser">>, + #{<<"name">> => ?USER1 + } + }, + {"200", <<"ok">>}), + + ok. + + +permissions_class_switch_tests(C, Node) -> + ?LOG_INFO("* permissions_class_switch_tests"), + ok = rpc:call( + Node, application, set_env, + [riak_admin_api, sec_group, [{superuser, false}, + {admin, true}, + {monitoring, true}]]), + assert_req( + C, {<<"SecurityListUsers">>, #{}}, + {"403", <<"Request disabled">>}), + + ok = rpc:call( + Node, application, set_env, + [riak_admin_api, sec_group, [{superuser, true}, + {admin, true}, + {monitoring, false}]]), + assert_req( + C, {<<"ClusterGetStatus">>, #{}}, + {"403", <<"Request disabled">>}), + + ok = rpc:call( + Node, application, set_env, + [riak_admin_api, sec_group, [{superuser, true}, + {admin, true}, + {monitoring, true}]]), + ok. + + +group_permissions_tests(C, _) -> + ?LOG_INFO("* group_permissions_tests"), + U2n = <<"u2">>, + U2p = <<"kkk">>, + assert_req( + C, {<<"SecurityCreateUser">>, + #{<<"name">> => U2n, + <<"auth_details">> => #{<<"method">> => <<"password">>, + <<"password">> => U2p}, + <<"permissions">> => [<<"cluster_observer">>] + } + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"SecurityCreateGroup">>, + #{<<"name">> => <<"g2">>, + <<"permissions">> => [<<"cluster_admin">>]} + }, + {"200", <<"ok">>}), + + assert_req( + C, {<<"ClusterPlan">>, #{}}, + {"403", <<"Not authorised">>}, {U2n, U2p}), + + assert_req( + C, {<<"SecurityAddUserGroups">>, #{<<"user">> => U2n, + <<"groups">> => [<<"g2">>]}}, + {"200", <<"ok">>}), + + patient_assert_req( + C, {<<"ClusterPlan">>, #{}}, + {"200", + fun(#{}) -> ok; (_) -> not_ok end}, + {U2n, U2p}), + + assert_req( + C, {<<"SecurityDeleteUser">>, #{<<"name">> => U2n}}, + {"200", <<"ok">>}), + assert_req( + C, {<<"SecurityDeleteGroup">>, #{<<"name">> => <<"g2">>}}, + {"200", <<"ok">>}), + + ok. + + + +httpc_options({N, P}) when is_binary(N) -> + httpc_options({binary_to_list(N), binary_to_list(P)}); +httpc_options(Creds) -> + CertDir = rt_config:get(rt_scratch_dir) ++ "/http_certs", + [{is_ssl, true}, + {basic_auth, Creds}, + {ssl_options, + [{cacertfile, + filename:join([CertDir, "rootCA/cert.pem"]) + }, + {verify, verify_none}, + {reuse_sessions, false} + ] + } + ]. + + +assert_req(IPPort, Req, Exp) -> + assert_req(IPPort, Req, Exp, {?ADMIN_USERNAME, ?ADMIN_PASSWD}). +assert_req({IP, Port}, {Action, Params}, {ExpStatusCode, ExpResult}, Creds) -> + ?LOG_INFO("~s", [Action]), + {ok, StatusCode, _, RespBody} = + ibrowse:send_req( + ff("https://~s:~b/ctl/~s", [IP, Port, Action]), [], post, + mochijson2:encode(#{params => Params}), + httpc_options(Creds) + ), + Key = + case ExpStatusCode of + GoodStatus when GoodStatus == "200" -> + <<"result">>; + _BadStatus -> + <<"error">> + end, + TopObj = maps:get(Key, mochijson2:decode(RespBody, [{format, map}])), + ?assertEqual(ExpStatusCode, StatusCode), + if is_function(ExpResult) -> + ExpResult(TopObj); + el/=se -> + ?assertMatch(ExpResult, TopObj) + end. + +patient_assert_req(IPPort, Req, Exp) -> + patient_assert_req(IPPort, Req, Exp, {?ADMIN_USERNAME, ?ADMIN_PASSWD}). +patient_assert_req({IP, Port} = C, {Action, Params}, {ExpStatusCode, ExpResult}, Creds) -> + ?LOG_INFO("~s", [Action]), + {ok, StatusCode, _, RespBody} = + ibrowse:send_req( + ff("https://~s:~b/ctl/~s", [IP, Port, Action]), [], post, + mochijson2:encode(#{params => Params}), + httpc_options(Creds) + ), + Key = + case ExpStatusCode of + GoodStatus when GoodStatus == "200" -> + <<"result">>; + _BadStatus -> + <<"error">> + end, + TopObj = maps:get(Key, mochijson2:decode(RespBody, [{format, map}])), + if is_function(ExpResult) -> + case ExpResult(TopObj) of + ok -> ok; + _ -> + ?LOG_INFO("."), + timer:sleep(1000), + patient_assert_req(C, {Action, Params}, {ExpStatusCode, ExpResult}) + end; + el/=se -> + if ExpResult =:= TopObj, + ExpStatusCode == StatusCode -> + ok; + el/=se -> + ?LOG_INFO("."), + timer:sleep(1000), + patient_assert_req(C, {Action, Params}, {ExpStatusCode, ExpResult}) + end + end. + +new_admin_blob() -> + iolist_to_binary( + ff("{\"name\": \"~s\", + \"password\": \"~s\", + \"expires\": \"never\", + \"permissions\": \"all\"}", [?ADMIN_USERNAME, ?ADMIN_PASSWD] + ) + ). + + +ff(F, A) -> + lists:flatten(io_lib:format(F, A)). diff --git a/tests/riak_admin_admin_api_console_tests.erl b/tests/riak_admin_admin_api_console_tests.erl new file mode 100644 index 0000000..49f0c4b --- /dev/null +++ b/tests/riak_admin_admin_api_console_tests.erl @@ -0,0 +1,334 @@ +%% ------------------------------------------------------------------- +%% +%% Copyright (c) 2026 TI Tokyo. +%% +%% This file is provided to you under the Apache License, +%% Version 2.0 (the "License"); you may not use this file +%% except in compliance with the License. You may obtain +%% a copy of the License at +%% +%% http://www.apache.org/licenses/LICENSE-2.0 +%% +%% Unless required by applicable law or agreed to in writing, +%% software distributed under the License is distributed on an +%% "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +%% KIND, either express or implied. See the License for the +%% specific language governing permissions and limitations +%% under the License. +%% +%% ------------------------------------------------------------------- +-module(riak_admin_admin_api_console_tests). +-behavior(riak_test). + +-export([confirm/0]). + +-include_lib("kernel/include/logger.hrl"). +-include_lib("stdlib/include/assert.hrl"). + +confirm() -> + CertDir = rt_config:get(rt_scratch_dir) ++ "/http_certs", + make_certs:rootCA(CertDir, "rootCA"), + make_certs:endusers(CertDir, "rootCA", ["site1.basho.com"]), + + _PrivDir = rt:priv_dir(), + Conf = + [{riak_core, [{ssl, + [{certfile, + filename:join( + [CertDir, "site1.basho.com/cert.pem"]) + }, + {keyfile, + filename:join( + [CertDir, "site1.basho.com/key.pem"]) + }, + {cacertfile, + filename:join( + [CertDir, "site1.basho.com/cacerts.pem"]) + } + ] + } + ] + }, + {riak_admin_api, [{admin_api_enabled, true}]} + ], + [Node1] = rt:build_cluster(1, Conf), + + status_tests(Node1), + user_crud_tests(Node1), + user_various_perms_and_expiry(Node1), + user_add_errors_test(Node1), + + pass. + + +status_tests(N) -> + ?LOG_INFO("status_tests"), + check_admin_cmd( + N, "admin-api status", + {re, "| *true *| *true *| *0 *| *0 *|"}), + check_admin_cmd( + N, "admin-api status disable", + empty), + check_admin_cmd( + N, "admin-api status", + {re, "| *false *| *true *| *0 *| *0 *|"}), + check_admin_cmd( + N, "admin-api status enable", + empty), + check_admin_cmd( + N, "admin-api status", + {re, "| *true *| *true *| *0 *| *0 *|"}), + + check_admin_cmd( + N, "admin-api reset", + {re, "All groups and users deleted"}), + ok. + +user_crud_tests(N) -> + ?LOG_INFO("user_crud_tests"), + Tmp = rt_config:get(rt_scratch_dir), + check_admin_cmd( + N, "admin-api list-users", + empty), + + U1 = {"john", "john's PassWoRd", never, all}, + US1 = filename:join([Tmp, "userspec-1"]), + file:write_file(US1, new_user_blob(U1)), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + empty), + check_admin_cmd( + N, "admin-api status", + {re, "| *true *| *true *| *1 *| *0 *|"}), + check_admin_cmd( + N, "admin-api list-users", fun(A) -> assert_user_in_clique_table(A, U1) end), + + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + fun assert_user_exists_error/1), + + check_admin_cmd( + N, ff("admin-api del-user ~s", [element(1, U1)]), + empty), + + check_admin_cmd( + N, ff("admin-api del-user ~s", [element(1, U1)]), + fun assert_no_such_user/1), + + check_admin_cmd( + N, "admin-api list-users", + empty), + ok. + +user_various_perms_and_expiry(N) -> + ?LOG_INFO("user_various_perms_and_expiry"), + Tmp = rt_config:get(rt_scratch_dir), + + U1 = {"jack1", "john's PassWoRd", never, [cluster_admin]}, + US1 = filename:join([Tmp, "userspec-1"]), + file:write_file(US1, new_user_blob(U1)), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + empty), + check_admin_cmd( + N, "admin-api list-users", fun(A) -> assert_user_in_clique_table(A, U1) end), + + U2 = {"jack2", "john's PassWoRd", os:system_time(millisecond) + 10_000, [cluster_admin]}, + US2 = filename:join([Tmp, "userspec-2"]), + file:write_file(US2, new_user_blob(U2)), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US2]), + empty), + check_admin_cmd( + N, "admin-api list-users", fun(A) -> assert_user_in_clique_table(A, U2) end), + + U3 = {"jack3", "john's PassWoRd", os:system_time(millisecond) + 10_000, [cluster_admin]}, + US3 = filename:join([Tmp, "userspec-2"]), + file:write_file(US3, new_user_blob(U3)), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US3]), + empty), + check_admin_cmd( + N, "admin-api list-users", fun(A) -> assert_user_in_clique_table(A, U3) end), + + U4 = {"jack4", "john's PassWoRd", calendar:system_time_to_rfc3339( + os:system_time(second) + 10, [{unit, second}]), [cluster_admin]}, + US4 = filename:join([Tmp, "userspec-2"]), + file:write_file(US4, new_user_blob(U4)), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US4]), + empty), + check_admin_cmd( + N, "admin-api list-users", fun(A) -> assert_user_in_clique_table(A, U4) end), + + check_admin_cmd( + N, "admin-api reset", + {re, "All groups and users deleted"}), + ok. + +assert_user_exists_error(A) -> + ["User already exists"|_] = string:split(A, "\n", all). + +assert_no_such_user(A) -> + ["No such user"|_] = string:split(A, "\n", all). + + +user_add_errors_test(N) -> + ?LOG_INFO("user_add_errors_test"), + Tmp = rt_config:get(rt_scratch_dir), + US1 = filename:join([Tmp, "bad-userspec"]), + + file:write_file( + US1, + <<"Bad Json[ where's my wool?">>), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + fun assert_badjson_error/1), + check_admin_cmd( + N, "admin-api list-users", + empty), + + file:write_file( + US1, + <<"{\"name\": \"john\", + \"password\": \"fafa\", + \"expires\": \"jamais\", + \"permissions\": [\"cluster_observer\"] + }">>), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + fun assert_bad_perms_or_expiry_error/1), + + file:write_file( + US1, + <<"{\"name\": \"john\", + \"password\": \"fafa\", + \"expires\": \"never\", + \"permissions\": [\"iddqd\"] + }">>), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + fun assert_bad_perms_or_expiry_error/1), + + NowSeconds = os:system_time(second), + Expired = calendar:system_time_to_rfc3339(NowSeconds - 1, [{unit, second}]), + file:write_file( + US1, + iolist_to_binary( + [<<"{\"name\": \"john\", + \"password\": \"fafa\", + \"expires\": \"">>, Expired, <<"\", + \"permissions\": [\"cluster_observer\"] + }">>])), + check_admin_cmd( + N, ff("admin-api add-user ~s", [US1]), + fun assert_bad_perms_or_expiry_error/1), + + ok. + +assert_badjson_error(A) -> + ["Invalid user specs"|_] = string:split(A, "\n", all). + +assert_bad_perms_or_expiry_error(A) -> + ["Invalid user permissions or expiry"|_] = string:split(A, "\n", all). + + +new_user_blob({Name, Password, Expires, Perms}) -> + iolist_to_binary( + ff("{\"name\": \"~s\", + \"password\": \"~s\", + \"expires\": ~s, + \"permissions\": ~s}", [Name, Password, expires_s(Expires), perms_s(Perms)] + ) + ). + +expires_s(never) -> + "\"never\""; +expires_s(TS) when is_integer(TS) -> + integer_to_list(TS); +expires_s(Rfc3339String) -> + "\"" ++ Rfc3339String ++ "\"". + +perms_s(all) -> + "\"all\""; +perms_s(Some) -> + "[" ++ string:join(["\"" ++ atom_to_list(A) ++ "\"" || A <- Some], ",") ++ "]". + + +assert_user_in_clique_table("", _) -> false; +assert_user_in_clique_table(Out, UserSpec) -> + SS = lists:nthtail( + 3, %% skip table header + string:split(Out, "\n", all)), + lists:any( + fun(S) -> + Row = [string:strip(A, both, $ ) || A <- string:split(S, "|", all)], + user_matches_human_printed_record(UserSpec, Row) + end, SS + ). + +user_matches_human_printed_record({Name, _, ExpiresIn, PermsIn}, + [Name, _, _Created, Modified, ExpiresOut, PermsOut, "password"]) -> + expires_matches(ExpiresIn, ExpiresOut) and perms_match(PermsIn, PermsOut) + and recent_enough(Modified); +user_matches_human_printed_record(_, _) -> + false. + + +expires_matches(never, "never") -> + true; +expires_matches(_, "never") -> + false; +expires_matches(never, _) -> + false; +expires_matches(TS, Rfc) -> + TS == calendar:rfc3339_to_system_time(Rfc, [{unit, millisecond}]). + +perms_match(all, "*") -> + true; +perms_match(all, _) -> + false; +perms_match(_, "*") -> + false; +perms_match(PP, SS_) -> + lists:usort([fmtp(P) || P <- PP]) == lists:usort(string:split(SS_, ",", all)). + +fmtp(cluster_admin) -> "adm"; +fmtp(cluster_observer) -> "obs"; +fmtp(security) -> "sec". + +recent_enough(Rfc) -> + Now = os:system_time(second), + TS = calendar:rfc3339_to_system_time(Rfc, [{unit, second}]), + if Now > TS -> + Now - TS < 2; + el/=se -> + TS - Now < 2 + end. + + + +check_admin_cmd(Node, Cmd, any) -> + S = string:tokens(Cmd, " "), + {ok, _} = rt:admin(Node, S), + ok; +check_admin_cmd(Node, Cmd, AssertFun) when is_function(AssertFun) -> + S = string:tokens(Cmd, " "), + {ok, Out} = rt:admin(Node, S), + AssertFun(Out); +check_admin_cmd(Node, Cmd, empty) -> + S = string:tokens(Cmd, " "), + {ok, Out} = rt:admin(Node, S), + true = ((Out == "\nok\n") or (Out == "ok\n")); +check_admin_cmd(Node, Cmd, {re, Expect}) -> + S = string:tokens(Cmd, " "), + {ok, Out} = rt:admin(Node, S), + case re:run(Out, Expect) of + nomatch -> + fail; + _ -> + ok + end. + +ff(F, A) -> + lists:flatten(io_lib:format(F, A)).