IMP-171: Add route blacklist check (#119)

* added without tests

* fixed

* added tests

* bumped, fixed, added explanation

* fixed explanations

* bumped cache

* fixed explanation

* fixed

* fixed
This commit is contained in:
Артем 2024-03-18 12:07:47 +03:00 committed by GitHub
parent 578bbc8571
commit 872e076d33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 244 additions and 18 deletions

View File

@ -38,4 +38,4 @@ jobs:
thrift-version: ${{ needs.setup.outputs.thrift-version }}
run-ct-with-compose: true
use-coveralls: true
cache-version: v5
cache-version: v6

View File

@ -1,10 +1,12 @@
-module(hg_inspector).
-export([check_blacklist/1]).
-export([inspect/4]).
-export([compare_risk_score/2]).
-export_type([risk_score/0]).
-export_type([blacklist_context/0]).
-include_lib("damsel/include/dmsl_domain_thrift.hrl").
-include_lib("damsel/include/dmsl_proxy_inspector_thrift.hrl").
@ -15,6 +17,48 @@
-type inspector() :: dmsl_domain_thrift:'Inspector'().
-type risk_score() :: dmsl_domain_thrift:'RiskScore'().
-type risk_magnitude() :: integer().
-type domain_revision() :: dmsl_domain_thrift:'DataRevision'().
-type blacklist_context() :: #{
route => hg_route:t(),
revision := domain_revision(),
token => binary(),
inspector := inspector()
}.
-spec check_blacklist(blacklist_context()) -> boolean().
check_blacklist(#{
route := Route,
revision := Revision,
token := Token,
inspector := #domain_Inspector{
proxy = Proxy
}
}) when Token =/= undefined ->
#domain_ProviderRef{id = ProviderID} = hg_route:provider_ref(Route),
#domain_TerminalRef{id = TerminalID} = hg_route:terminal_ref(Route),
Context = #proxy_inspector_BlackListContext{
first_id = genlib:to_binary(ProviderID),
second_id = genlib:to_binary(TerminalID),
field_name = <<"CARD_TOKEN">>,
value = Token
},
Result = issue_call(
'IsBlacklisted',
{Context},
hg_proxy:get_call_options(
Proxy,
Revision
)
),
case Result of
{ok, Check} when is_atom(Check) ->
Check;
{exception, Error} ->
error(Error)
end;
check_blacklist(_Ctx) ->
false.
-spec inspect(shop(), invoice(), payment(), inspector()) -> risk_score() | no_return().
inspect(
@ -113,6 +157,9 @@ get_payment_info(
payment = ProxyPayment
}.
issue_call(Func, Args, CallOpts) ->
issue_call(Func, Args, CallOpts, undefined, undefined).
issue_call(Func, Args, CallOpts, undefined, _DeadLine) ->
% Do not set custom deadline without fallback risk score
hg_woody_wrapper:call(proxy_inspector, Func, Args, CallOpts);

View File

@ -813,6 +813,8 @@ log_rejected_routes(limit_misconfiguration, Routes, _VS) ->
?LOG_MD(warning, "Limiter hold error caused route candidates to be rejected: ~p", [Routes]);
log_rejected_routes(limit_overflow, Routes, _VS) ->
?LOG_MD(notice, "Limit overflow caused route candidates to be rejected: ~p", [Routes]);
log_rejected_routes(in_blacklist, Routes, _VS) ->
?LOG_MD(notice, "Route candidates are blacklisted: ~p", [Routes]);
log_rejected_routes(adapter_unavailable, Routes, _VS) ->
?LOG_MD(notice, "Adapter unavailability caused route candidates to be rejected: ~p", [Routes]);
log_rejected_routes(provider_conversion_is_too_low, Routes, _VS) ->
@ -1968,6 +1970,7 @@ run_routing_decision_pipeline(Ctx0, VS, St) ->
%% accounted for in `St`.
fun(Ctx) -> filter_routes_with_limit_hold(Ctx, VS, get_iter(St) + 1, St) end,
fun(Ctx) -> filter_routes_by_limit_overflow(Ctx, VS, St) end,
fun(Ctx) -> hg_routing:filter_by_blacklist(Ctx, build_blacklist_context(St)) end,
fun hg_routing:filter_by_critical_provider_status/1,
fun hg_routing:choose_route_with_ctx/1
]
@ -2023,6 +2026,28 @@ build_routing_context(PaymentInstitution, VS, Revision, St) ->
gather_routes(PaymentInstitution, VS, Revision, St)
end.
build_blacklist_context(St) ->
Revision = get_payment_revision(St),
#domain_InvoicePayment{payer = Payer} = get_payment(St),
Token =
case get_payer_payment_tool(Payer) of
{bank_card, #domain_BankCard{token = CardToken}} ->
CardToken;
_ ->
undefined
end,
Opts = get_opts(St),
VS1 = get_varset(St, #{}),
PaymentInstitutionRef = get_payment_institution_ref(Opts),
PaymentInstitution = hg_payment_institution:compute_payment_institution(PaymentInstitutionRef, VS1, Revision),
InspectorRef = get_selector_value(inspector, PaymentInstitution#domain_PaymentInstitution.inspector),
Inspector = hg_domain:get(Revision, {inspector, InspectorRef}),
#{
revision => Revision,
token => Token,
inspector => Inspector
}.
filter_attempted_routes(Ctx, #st{routes = AttemptedRoutes}) ->
lists:foldr(
fun(R, C) ->

View File

@ -16,6 +16,31 @@ get_service_spec() ->
{"/test/proxy/inspector/dummy", {dmsl_proxy_inspector_thrift, 'InspectorProxy'}}.
-spec handle_function(woody:func(), woody:args(), hg_woody_service_wrapper:handler_opts()) -> term() | no_return().
handle_function(
'IsBlacklisted',
{#proxy_inspector_BlackListContext{
value = <<"inspector_fail_first">>,
second_id = <<"1">>
}},
_Options
) ->
true;
handle_function(
'IsBlacklisted',
{#proxy_inspector_BlackListContext{
value = <<"inspector_fail_all">>
}},
_Options
) ->
true;
handle_function(
'IsBlacklisted',
{#proxy_inspector_BlackListContext{
value = _Token
}},
_Options
) ->
false;
handle_function(
'InspectPayment',
{#proxy_inspector_Context{

View File

@ -282,6 +282,12 @@ process_payment(?processed(), undefined, PaymentInfo, CtxOpts, _) ->
no_preauth ->
%% simple workflow without 3DS
maybe_fail(PaymentInfo, CtxOpts, result(?sleep(0), <<"sleeping">>));
inspector_fail_first ->
%% simple workflow without 3DS
result(?sleep(0), <<"sleeping">>);
inspector_fail_all ->
%% simple workflow without 3DS
result(?sleep(0), <<"sleeping">>);
empty_cvv ->
%% simple workflow without 3DS
result(?sleep(0), <<"sleeping">>);
@ -335,6 +341,8 @@ process_payment(?processed(), undefined, PaymentInfo, CtxOpts, _) ->
process_payment(?processed(), <<"sleeping">>, PaymentInfo, CtxOpts, _) ->
TrxID = hg_utils:construct_complex_id([get_payment_id(PaymentInfo), get_ctx_opts_override(CtxOpts)]),
case get_payment_info_scenario(PaymentInfo) of
inspector_fail_first ->
finish(success(PaymentInfo), mk_trx(TrxID, PaymentInfo));
change_cash_increase ->
finish(success(PaymentInfo, get_payment_increased_cost(PaymentInfo)), mk_trx(TrxID, PaymentInfo));
change_cash_decrease ->
@ -651,6 +659,10 @@ get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"no_preauth_t
no_preauth_timeout_failure;
get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"no_preauth_suspend_default">>}}) ->
no_preauth_suspend_default;
get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"inspector_fail_first">>}}) ->
inspector_fail_first;
get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"inspector_fail_all">>}}) ->
inspector_fail_all;
get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"empty_cvv">>}}) ->
empty_cvv;
get_payment_tool_scenario({'bank_card', #domain_BankCard{token = <<"preauth_3ds:timeout=", Timeout/binary>>}}) ->
@ -736,6 +748,10 @@ make_payment_tool(Code, PSys) when
Code =:= unexpected_failure_no_trx
->
?SESSION42(make_bank_card_payment_tool(atom_to_binary(Code, utf8), PSys));
make_payment_tool(inspector_fail_first, PSys) ->
?SESSION42(make_bank_card_payment_tool(<<"inspector_fail_first">>, PSys));
make_payment_tool(inspector_fail_all, PSys) ->
?SESSION42(make_bank_card_payment_tool(<<"inspector_fail_all">>, PSys));
make_payment_tool(empty_cvv, PSys) ->
{_, BCard} = make_bank_card_payment_tool(<<"empty_cvv">>, PSys),
?SESSION42({bank_card, BCard#domain_BankCard{is_cvv_empty = true}});

View File

@ -13,6 +13,8 @@
-export([payment_start_idempotency/1]).
-export([payment_success/1]).
-export([payment_w_first_blacklisted_success/1]).
-export([payment_w_all_blacklisted/1]).
-export([register_payment_success/1]).
-export([register_payment_customer_payer_success/1]).
-export([payment_success_additional_info/1]).
@ -51,6 +53,8 @@ groups() ->
{payments, [parallel], [
payment_start_idempotency,
payment_success,
payment_w_first_blacklisted_success,
payment_w_all_blacklisted,
register_payment_success,
register_payment_customer_payer_success,
payment_success_additional_info,
@ -189,6 +193,54 @@ payment_success(C) ->
Trx
).
-spec payment_w_first_blacklisted_success(config()) -> test_return().
payment_w_first_blacklisted_success(C) ->
Client = cfg(client, C),
InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C),
{PaymentTool, Session} = hg_dummy_provider:make_payment_tool(inspector_fail_first, ?pmt_sys(<<"visa-ref">>)),
PaymentParams = make_payment_params(PaymentTool, Session, instant),
PaymentID = process_payment(InvoiceID, PaymentParams, Client),
PaymentID = await_payment_capture(InvoiceID, PaymentID, Client),
?invoice_state(
?invoice_w_status(?invoice_paid()),
[_PaymentSt]
) = hg_client_invoicing:get(InvoiceID, Client),
_Explanation =
#payproc_InvoicePaymentExplanation{
explained_routes = [
#payproc_InvoicePaymentRouteExplanation{
route = ?route(?prv(1), ?trm(2)),
is_chosen = true
},
#payproc_InvoicePaymentRouteExplanation{
route = ?route(?prv(1), ?trm(1)),
is_chosen = false,
rejection_description = Desc
}
]
} = hg_client_invoicing:explain_route(InvoiceID, PaymentID, Client),
?assertEqual(
<<"Route was blacklisted {domain_PaymentRoute,{domain_ProviderRef,1},{domain_TerminalRef,1}}.">>, Desc
).
-spec payment_w_all_blacklisted(config()) -> test_return().
payment_w_all_blacklisted(C) ->
Client = cfg(client, C),
InvoiceID = start_invoice(<<"rubberduck">>, make_due_date(10), 42000, C),
{PaymentTool, Session} = hg_dummy_provider:make_payment_tool(inspector_fail_all, ?pmt_sys(<<"visa-ref">>)),
PaymentParams = make_payment_params(PaymentTool, Session, instant),
?payment_state(?payment(PaymentID)) = hg_client_invoicing:start_payment(InvoiceID, PaymentParams, Client),
[
?payment_ev(PaymentID, ?payment_started(?payment_w_status(?pending()))),
?payment_ev(PaymentID, ?risk_score_changed(_RiskScore)),
?payment_ev(PaymentID, ?route_changed(_Route)),
?payment_ev(PaymentID, ?payment_rollback_started({failure, _Failure}))
] = next_changes(InvoiceID, 4, Client),
?invoice_state(
?invoice_w_status(?invoice_unpaid()),
[_PaymentSt]
) = hg_client_invoicing:get(InvoiceID, Client).
-spec register_payment_success(config()) -> test_return().
register_payment_success(C) ->
Client = cfg(client, C),
@ -580,7 +632,7 @@ construct_domain_fixture() ->
allocations = #domain_PaymentAllocationServiceTerms{
allow = {constant, true}
},
attempt_limit = {value, #domain_AttemptLimit{attempts = 2}}
attempt_limit = {value, #domain_AttemptLimit{attempts = 1}}
},
recurrent_paytools = #domain_RecurrentPaytoolsServiceTerms{
payment_methods =
@ -623,7 +675,8 @@ construct_domain_fixture() ->
?ruleset(1),
<<"Policies">>,
{candidates, [
?candidate({constant, true}, ?trm(1))
?candidate({constant, true}, ?trm(1)),
?candidate({constant, true}, ?trm(2))
]}
),
hg_ct_fixture:construct_payment_routing_ruleset(
@ -809,6 +862,14 @@ construct_domain_fixture() ->
provider_ref = ?prv(1)
}
}},
{terminal, #domain_TerminalObject{
ref = ?trm(2),
data = #domain_Terminal{
name = <<"Brominal 2">>,
description = <<"Brominal 2">>,
provider_ref = ?prv(1)
}
}},
hg_ct_fixture:construct_mobile_operator(?mob(<<"mts-ref">>), <<"mts mobile operator">>),
hg_ct_fixture:construct_payment_service(?pmt_srv(<<"qiwi-ref">>), <<"qiwi payment service">>),

View File

@ -20,6 +20,7 @@
%%
-export([filter_by_critical_provider_status/1]).
-export([filter_by_blacklist/2]).
-export([choose_route_with_ctx/1]).
%%
@ -50,6 +51,7 @@
-type route_groups_by_priority() :: #{{availability_condition(), terminal_priority_rating()} => [fail_rated_route()]}.
-type fail_rated_route() :: {hg_route:t(), provider_status()}.
-type blacklisted_route() :: {hg_route:t(), boolean()}.
-type scored_route() :: {route_scores(), hg_route:t()}.
@ -83,6 +85,7 @@
-export_type([route_predestination/0]).
-export_type([route_choice_context/0]).
-export_type([fail_rated_route/0]).
-export_type([blacklisted_route/0]).
-export_type([route_scores/0]).
-export_type([limits/0]).
-export_type([scores/0]).
@ -106,6 +109,24 @@ filter_by_critical_provider_status(Ctx0) ->
RoutesFailRates
).
-spec filter_by_blacklist(T, hg_inspector:blacklist_context()) -> T when T :: hg_routing_ctx:t().
filter_by_blacklist(Ctx, BlCtx) ->
BlacklistedRoutes = check_routes(hg_routing_ctx:candidates(Ctx), BlCtx),
lists:foldr(
fun
({R, true = Status}, C) ->
R1 = hg_route:to_rejected_route(R, {'InBlackList', Status}),
Ctx0 = hg_routing_ctx:reject(in_blacklist, R1, C),
Scores0 = score_route(R),
Scores1 = Scores0#domain_PaymentRouteScores{blacklist_condition = 1},
hg_routing_ctx:add_route_scores({hg_route:to_payment_route(R), Scores1}, Ctx0);
({_R, _ProviderStatus}, C) ->
C
end,
Ctx,
BlacklistedRoutes
).
-spec choose_route_with_ctx(T) -> T when T :: hg_routing_ctx:t().
choose_route_with_ctx(Ctx) ->
Candidates = hg_routing_ctx:candidates(Ctx),
@ -281,6 +302,12 @@ compute_rule_set(RuleSetRef, VS, Revision) ->
),
RuleSet.
-spec check_routes([hg_route:t()], hg_inspector:blacklist_context()) -> [blacklisted_route()].
check_routes([], _BlCtx) ->
[];
check_routes(Routes, BlCtx) ->
[{R, hg_inspector:check_blacklist(BlCtx#{route => R})} || R <- Routes].
-spec rate_routes([hg_route:t()]) -> [fail_rated_route()].
rate_routes(Routes) ->
score_routes_with_fault_detector(Routes).
@ -451,7 +478,7 @@ calc_random_condition(StartFrom, Random, [FailRatedRoute | Rest], Routes) ->
score_routes_map(Routes) ->
lists:foldl(
fun({Route, _} = FailRatedRoute, Acc) ->
Acc#{hg_route:to_payment_route(Route) => score_route(FailRatedRoute)}
Acc#{hg_route:to_payment_route(Route) => score_route_ext(FailRatedRoute)}
end,
#{},
Routes
@ -459,24 +486,30 @@ score_routes_map(Routes) ->
-spec score_routes([fail_rated_route()]) -> [scored_route()].
score_routes(Routes) ->
[{score_route(FailRatedRoute), Route} || {Route, _} = FailRatedRoute <- Routes].
[{score_route_ext(FailRatedRoute), Route} || {Route, _} = FailRatedRoute <- Routes].
score_route({Route, ProviderStatus}) ->
score_route_ext({Route, ProviderStatus}) ->
{AvailabilityStatus, ConversionStatus} = ProviderStatus,
{AvailabilityCondition, Availability} = get_availability_score(AvailabilityStatus),
{ConversionCondition, Conversion} = get_conversion_score(ConversionStatus),
Scores = score_route(Route),
Scores#domain_PaymentRouteScores{
availability_condition = AvailabilityCondition,
conversion_condition = ConversionCondition,
availability = Availability,
conversion = Conversion
}.
score_route(Route) ->
PriorityRate = hg_route:priority(Route),
RandomCondition = hg_route:weight(Route),
Pin = hg_route:pin(Route),
PinHash = erlang:phash2(Pin),
{AvailabilityStatus, ConversionStatus} = ProviderStatus,
{AvailabilityCondition, Availability} = get_availability_score(AvailabilityStatus),
{ConversionCondition, Conversion} = get_conversion_score(ConversionStatus),
#domain_PaymentRouteScores{
availability_condition = AvailabilityCondition,
conversion_condition = ConversionCondition,
terminal_priority_rating = PriorityRate,
route_pin = PinHash,
random_condition = RandomCondition,
availability = Availability,
conversion = Conversion
blacklist_condition = 0
}.
get_availability_score({alive, FailRate}) -> {1, 1.0 - FailRate};

View File

@ -22,11 +22,13 @@
-export([stash_route_limits/2]).
-export([route_scores/1]).
-export([stash_route_scores/2]).
-export([add_route_scores/2]).
-type rejection_group() :: atom().
-type error() :: {atom(), _Description}.
-type route_limits() :: hg_routing:limits().
-type route_scores() :: hg_routing:scores().
-type one_route_scores() :: {hg_route:payment_route(), hg_routing:route_scores()}.
-type t() :: #{
initial_candidates := [hg_route:t()],
@ -173,9 +175,15 @@ route_scores(Ctx) ->
maps:get(route_scores, Ctx, undefined).
-spec stash_route_scores(route_scores(), t()) -> t().
stash_route_scores(RouteScoresNew, Ctx = #{route_scores := RouteScores}) ->
Ctx#{route_scores => maps:merge(RouteScores, RouteScoresNew)};
stash_route_scores(RouteScores, Ctx) ->
Ctx#{route_scores => RouteScores}.
-spec add_route_scores(one_route_scores(), t()) -> t().
add_route_scores({PR, Scores}, Ctx) ->
Ctx#{route_scores => #{PR => Scores}}.
%%
latest_rejected_routes(#{latest_rejection := ReasonGroup, rejections := Rejections}) ->

View File

@ -139,6 +139,11 @@ candidate_rejection_explanation(
<<"We only know about limits for this route, but no limit",
" was reached, if you see this message contact developer.">>,
check_route_limits(RouteLimits, IfEmpty);
candidate_rejection_explanation(
R = #{scores := #domain_PaymentRouteScores{blacklist_condition = 1}},
_
) ->
check_route_blacklisted(R);
candidate_rejection_explanation(
#{scores := RouteScores, limits := RouteLimits},
#{scores := ChosenScores}
@ -152,12 +157,13 @@ candidate_rejection_explanation(
IfEmpty = <<"No explanation for rejection can be found. Check in with developer.">>,
check_route_limits(RouteLimits, IfEmpty);
candidate_rejection_explanation(
#{scores := RouteScores, limits := RouteLimits},
R = #{scores := RouteScores, limits := RouteLimits},
#{scores := ChosenScores}
) when RouteScores < ChosenScores ->
Explanation0 = check_route_scores(RouteScores, ChosenScores),
Explanation1 = check_route_limits(RouteLimits, <<"">>),
genlib_string:join(<<" ">>, [Explanation0, Explanation1]).
Explanation0 = check_route_blacklisted(R),
Explanation1 = check_route_scores(RouteScores, ChosenScores),
Explanation2 = check_route_limits(RouteLimits, <<"">>),
genlib_string:join(<<" ">>, [Explanation0, Explanation1, Explanation2]).
check_route_limits(RouteLimits, IfEmpty) ->
case check_route_limits(RouteLimits) of
@ -261,6 +267,11 @@ check_route_scores(
) when Cv0 < Cv1 ->
format("Conversion is less than in chosen route ~p < ~p.", [Cv0, Cv1]).
check_route_blacklisted(#{route := R, scores := #domain_PaymentRouteScores{blacklist_condition = 1}}) ->
format("Route was blacklisted ~w.", [R]);
check_route_blacklisted(_) ->
<<"">>.
gather_varset(Payment, Opts) ->
#domain_InvoicePayment{
cost = Cost,

View File

@ -21,7 +21,7 @@
{<<"ctx">>,{pkg,<<"ctx">>,<<"0.6.0">>},2},
{<<"damsel">>,
{git,"https://github.com/valitydev/damsel.git",
{ref,"decfa45d7ce4b3c948957c6ddba34742aaa9fdc5"}},
{ref,"f8e56c683617eca4e2ecb2ea6a8eec97c6c1dac9"}},
0},
{<<"dmt_client">>,
{git,"https://github.com/valitydev/dmt-client.git",