feat(calendar): validate org defaults in calendar.settings
Documented keys default_location, default_duration_minutes, default_recurrence; unknown keys (week_patterns) passthrough; invalid known shape returns 400. Refs EventHub/EventHubBack#63
This commit is contained in:
@@ -154,6 +154,9 @@ update_calendar(Req) ->
|
|||||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
|
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||||
|
{error, {invalid_settings, Key}} when is_binary(Key) ->
|
||||||
|
handler_utils:send_error(Req2, 400,
|
||||||
|
<<"Invalid settings.", Key/binary>>);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||||
end;
|
end;
|
||||||
|
|||||||
+110
-17
@@ -4,6 +4,7 @@
|
|||||||
-export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1,
|
-export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1,
|
||||||
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
|
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
|
||||||
-export([can_access/2, can_edit/2, booking_open/1]).
|
-export([can_access/2, can_edit/2, booking_open/1]).
|
||||||
|
-export([normalize_settings/1]).
|
||||||
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
|
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
|
||||||
|
|
||||||
%% Создание календаря с политикой по умолчанию (manual)
|
%% Создание календаря с политикой по умолчанию (manual)
|
||||||
@@ -109,12 +110,16 @@ update_calendar(UserId, CalendarId, Updates) ->
|
|||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
case can_edit(UserId, Calendar) of
|
case can_edit(UserId, Calendar) of
|
||||||
true ->
|
true ->
|
||||||
ValidUpdates = validate_updates(Updates),
|
case validate_updates(Updates) of
|
||||||
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
|
||||||
{error, _} = Err ->
|
{error, _} = Err ->
|
||||||
Err;
|
Err;
|
||||||
{ok, ValidUpdates2} ->
|
{ok, ValidUpdates} ->
|
||||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err;
|
||||||
|
{ok, ValidUpdates2} ->
|
||||||
|
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
||||||
|
end
|
||||||
end;
|
end;
|
||||||
false ->
|
false ->
|
||||||
{error, access_denied}
|
{error, access_denied}
|
||||||
@@ -225,24 +230,41 @@ can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
|||||||
can_edit(_, _) ->
|
can_edit(_, _) ->
|
||||||
false.
|
false.
|
||||||
|
|
||||||
%% Валидация полей обновления
|
%% Валидация полей обновления.
|
||||||
|
%% Known settings keys with invalid shape → {error, {invalid_settings, Key}}.
|
||||||
|
%% Unknown settings keys (e.g. week_patterns) are preserved as-is.
|
||||||
validate_updates(Updates) ->
|
validate_updates(Updates) ->
|
||||||
lists:filtermap(fun(U) ->
|
validate_updates(Updates, []).
|
||||||
case normalize_update(U) of
|
|
||||||
false -> false;
|
validate_updates([], Acc) ->
|
||||||
Norm ->
|
{ok, lists:reverse(Acc)};
|
||||||
case validate_update(Norm) of
|
validate_updates([U | Rest], Acc) ->
|
||||||
true -> {true, Norm};
|
case normalize_update(U) of
|
||||||
false -> false
|
false ->
|
||||||
end
|
validate_updates(Rest, Acc);
|
||||||
end
|
{error, _} = Err ->
|
||||||
end, Updates).
|
Err;
|
||||||
|
Norm ->
|
||||||
|
case validate_update(Norm) of
|
||||||
|
true -> validate_updates(Rest, [Norm | Acc]);
|
||||||
|
false -> validate_updates(Rest, Acc)
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
normalize_update({type, <<"personal">>}) -> {type, personal};
|
normalize_update({type, <<"personal">>}) -> {type, personal};
|
||||||
normalize_update({type, <<"commercial">>}) -> {type, commercial};
|
normalize_update({type, <<"commercial">>}) -> {type, commercial};
|
||||||
normalize_update({type, personal}) -> {type, personal};
|
normalize_update({type, personal}) -> {type, personal};
|
||||||
normalize_update({type, commercial}) -> {type, commercial};
|
normalize_update({type, commercial}) -> {type, commercial};
|
||||||
normalize_update({<<"settings">>, Value}) when is_map(Value) -> {settings, Value};
|
normalize_update({<<"settings">>, Value}) when is_map(Value) ->
|
||||||
|
case normalize_settings(Value) of
|
||||||
|
{ok, Map} -> {settings, Map};
|
||||||
|
{error, _} = Err -> Err
|
||||||
|
end;
|
||||||
|
normalize_update({settings, Value}) when is_map(Value) ->
|
||||||
|
case normalize_settings(Value) of
|
||||||
|
{ok, Map} -> {settings, Map};
|
||||||
|
{error, _} = Err -> Err
|
||||||
|
end;
|
||||||
normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value};
|
normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value};
|
||||||
normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value};
|
normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value};
|
||||||
normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value};
|
normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value};
|
||||||
@@ -261,7 +283,7 @@ validate_update({short_name, Value}) when is_binary(Value) -> true;
|
|||||||
validate_update({category, Value}) when is_binary(Value) -> true;
|
validate_update({category, Value}) when is_binary(Value) -> true;
|
||||||
validate_update({color, Value}) when is_binary(Value) -> true;
|
validate_update({color, Value}) when is_binary(Value) -> true;
|
||||||
validate_update({image_url, Value}) when is_binary(Value) -> true;
|
validate_update({image_url, Value}) when is_binary(Value) -> true;
|
||||||
%% Opaque JSON object for client prefs (e.g. week_patterns for schedule fill).
|
%% Documented org-default keys validated in normalize_settings/1; unknown keys passthrough.
|
||||||
validate_update({settings, Value}) when is_map(Value) -> true;
|
validate_update({settings, Value}) when is_map(Value) -> true;
|
||||||
validate_update({confirmation, Value}) ->
|
validate_update({confirmation, Value}) ->
|
||||||
case Value of
|
case Value of
|
||||||
@@ -272,6 +294,77 @@ validate_update({confirmation, Value}) ->
|
|||||||
end;
|
end;
|
||||||
validate_update(_) -> false.
|
validate_update(_) -> false.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Validate documented calendar.settings keys for org defaults.
|
||||||
|
%%% Unknown keys are kept (forward-compat, e.g. week_patterns).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec normalize_settings(map()) -> {ok, map()} | {error, {invalid_settings, binary()}}.
|
||||||
|
normalize_settings(Map) when is_map(Map) ->
|
||||||
|
normalize_settings_keys(maps:keys(Map), Map).
|
||||||
|
|
||||||
|
normalize_settings_keys([], Map) ->
|
||||||
|
{ok, Map};
|
||||||
|
normalize_settings_keys([Key | Rest], Map) ->
|
||||||
|
case validate_settings_key(Key, maps:get(Key, Map)) of
|
||||||
|
ok ->
|
||||||
|
normalize_settings_keys(Rest, Map);
|
||||||
|
{error, _} = Err ->
|
||||||
|
Err
|
||||||
|
end.
|
||||||
|
|
||||||
|
validate_settings_key(<<"default_location">>, Val) ->
|
||||||
|
validate_default_location(Val);
|
||||||
|
validate_settings_key(<<"default_duration_minutes">>, Val) ->
|
||||||
|
validate_default_duration(Val);
|
||||||
|
validate_settings_key(<<"default_recurrence">>, Val) ->
|
||||||
|
validate_default_recurrence(Val);
|
||||||
|
validate_settings_key(_Unknown, _Val) ->
|
||||||
|
ok.
|
||||||
|
|
||||||
|
validate_default_location(Loc) when is_map(Loc) ->
|
||||||
|
case maps:get(<<"address">>, Loc, undefined) of
|
||||||
|
Address when is_binary(Address), byte_size(Address) > 0 ->
|
||||||
|
HasLat = maps:is_key(<<"lat">>, Loc),
|
||||||
|
HasLon = maps:is_key(<<"lon">>, Loc),
|
||||||
|
case {HasLat, HasLon} of
|
||||||
|
{false, false} ->
|
||||||
|
ok;
|
||||||
|
{true, true} ->
|
||||||
|
Lat = maps:get(<<"lat">>, Loc),
|
||||||
|
Lon = maps:get(<<"lon">>, Loc),
|
||||||
|
case is_number(Lat) andalso is_number(Lon) of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, {invalid_settings, <<"default_location">>}}
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
{error, {invalid_settings, <<"default_location">>}}
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
{error, {invalid_settings, <<"default_location">>}}
|
||||||
|
end;
|
||||||
|
validate_default_location(_) ->
|
||||||
|
{error, {invalid_settings, <<"default_location">>}}.
|
||||||
|
|
||||||
|
validate_default_duration(N) when is_integer(N), N >= 1, N =< 1440 ->
|
||||||
|
ok;
|
||||||
|
validate_default_duration(_) ->
|
||||||
|
{error, {invalid_settings, <<"default_duration_minutes">>}}.
|
||||||
|
|
||||||
|
validate_default_recurrence(null) ->
|
||||||
|
ok;
|
||||||
|
validate_default_recurrence(Rec) when is_map(Rec) ->
|
||||||
|
Enabled = maps:get(<<"enabled">>, Rec, undefined),
|
||||||
|
Freq = maps:get(<<"freq">>, Rec, undefined),
|
||||||
|
Interval = maps:get(<<"interval">>, Rec, undefined),
|
||||||
|
ValidFreq = Freq =:= <<"DAILY">> orelse Freq =:= <<"WEEKLY">> orelse Freq =:= <<"MONTHLY">>,
|
||||||
|
case is_boolean(Enabled) andalso ValidFreq andalso is_integer(Interval) andalso Interval >= 1 of
|
||||||
|
true -> ok;
|
||||||
|
false -> {error, {invalid_settings, <<"default_recurrence">>}}
|
||||||
|
end;
|
||||||
|
validate_default_recurrence(_) ->
|
||||||
|
{error, {invalid_settings, <<"default_recurrence">>}}.
|
||||||
|
|
||||||
%% ─── Административные функции ────────────────────────────────────────
|
%% ─── Административные функции ────────────────────────────────────────
|
||||||
-spec admin_list_all() -> {ok, [map()]} | {error, term()}.
|
-spec admin_list_all() -> {ok, [map()]} | {error, term()}.
|
||||||
admin_list_all() ->
|
admin_list_all() ->
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ test_update_calendar(Token, CalId) ->
|
|||||||
ct:pal(" OK").
|
ct:pal(" OK").
|
||||||
|
|
||||||
test_update_calendar_settings(Token, CalId) ->
|
test_update_calendar_settings(Token, CalId) ->
|
||||||
ct:pal(" TEST: Persist calendar.settings.week_patterns"),
|
ct:pal(" TEST: Persist calendar.settings week_patterns + org defaults"),
|
||||||
Path = <<"/v1/calendars/", CalId/binary>>,
|
Path = <<"/v1/calendars/", CalId/binary>>,
|
||||||
Settings = #{
|
Settings = #{
|
||||||
<<"week_patterns">> => [
|
<<"week_patterns">> => [
|
||||||
@@ -73,12 +73,26 @@ test_update_calendar_settings(Token, CalId) ->
|
|||||||
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>}
|
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
<<"default_location">> => #{
|
||||||
|
<<"address">> => <<"ул. Пример, 1">>,
|
||||||
|
<<"lat">> => 55.75,
|
||||||
|
<<"lon">> => 37.61
|
||||||
|
},
|
||||||
|
<<"default_duration_minutes">> => 60,
|
||||||
|
<<"default_recurrence">> => #{
|
||||||
|
<<"enabled">> => true,
|
||||||
|
<<"freq">> => <<"WEEKLY">>,
|
||||||
|
<<"interval">> => 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
Updated = api_test_runner:client_put(Path, Token, #{settings => Settings}),
|
Updated = api_test_runner:client_put(Path, Token, #{settings => Settings}),
|
||||||
?assertEqual(Settings, maps:get(<<"settings">>, Updated)),
|
?assertEqual(Settings, maps:get(<<"settings">>, Updated)),
|
||||||
Got = api_test_runner:client_get(Path, Token),
|
Got = api_test_runner:client_get(Path, Token),
|
||||||
?assertEqual(Settings, maps:get(<<"settings">>, Got)),
|
?assertEqual(Settings, maps:get(<<"settings">>, Got)),
|
||||||
|
Bad = api_test_runner:client_request(put, Path, Token,
|
||||||
|
jsx:encode(#{settings => #{default_location => #{address => <<"">>}}})),
|
||||||
|
?assertMatch({ok, 400, _, _}, Bad),
|
||||||
ct:pal(" OK").
|
ct:pal(" OK").
|
||||||
|
|
||||||
test_update_calendar_forbidden(OtherToken, CalId) ->
|
test_update_calendar_forbidden(OtherToken, CalId) ->
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ logic_calendar_test_() ->
|
|||||||
{"Update calendar test", fun test_update_calendar/0},
|
{"Update calendar test", fun test_update_calendar/0},
|
||||||
{"Persist calendar settings (week_patterns)", fun test_update_settings/0},
|
{"Persist calendar settings (week_patterns)", fun test_update_settings/0},
|
||||||
{"Reject non-map settings", fun test_update_settings_invalid/0},
|
{"Reject non-map settings", fun test_update_settings_invalid/0},
|
||||||
|
{"Org defaults settings round-trip", fun test_update_settings_org_defaults/0},
|
||||||
|
{"Reject invalid default_location shape", fun test_update_settings_invalid_location/0},
|
||||||
{"Delete calendar test", fun test_delete_calendar/0},
|
{"Delete calendar test", fun test_delete_calendar/0},
|
||||||
{"Access control test", fun test_access_control/0},
|
{"Access control test", fun test_access_control/0},
|
||||||
{"Ensure default calendar test", fun test_ensure_default_calendar/0}
|
{"Ensure default calendar test", fun test_ensure_default_calendar/0}
|
||||||
@@ -152,6 +154,66 @@ test_update_settings_invalid() ->
|
|||||||
UserId, Calendar#calendar.id, [{settings, [<<"not-a-map">>]}]),
|
UserId, Calendar#calendar.id, [{settings, [<<"not-a-map">>]}]),
|
||||||
?assertEqual(#{}, Same#calendar.settings).
|
?assertEqual(#{}, Same#calendar.settings).
|
||||||
|
|
||||||
|
test_update_settings_org_defaults() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual),
|
||||||
|
Patterns = [
|
||||||
|
#{
|
||||||
|
<<"id">> => <<"p1">>,
|
||||||
|
<<"name">> => <<"Weekdays">>,
|
||||||
|
<<"slots">> => [
|
||||||
|
#{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Settings = #{
|
||||||
|
<<"week_patterns">> => Patterns,
|
||||||
|
<<"default_location">> => #{
|
||||||
|
<<"address">> => <<"ул. Пример, 1">>,
|
||||||
|
<<"lat">> => 55.75,
|
||||||
|
<<"lon">> => 37.61
|
||||||
|
},
|
||||||
|
<<"default_duration_minutes">> => 90,
|
||||||
|
<<"default_recurrence">> => #{
|
||||||
|
<<"enabled">> => true,
|
||||||
|
<<"freq">> => <<"WEEKLY">>,
|
||||||
|
<<"interval">> => 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ok, Updated} = logic_calendar:update_calendar(
|
||||||
|
UserId, Calendar#calendar.id, [{settings, Settings}]),
|
||||||
|
?assertEqual(Settings, Updated#calendar.settings),
|
||||||
|
{ok, Reloaded} = logic_calendar:get_calendar(UserId, Calendar#calendar.id),
|
||||||
|
?assertEqual(Settings, Reloaded#calendar.settings),
|
||||||
|
%% null clears recurrence default
|
||||||
|
Settings2 = Settings#{<<"default_recurrence">> => null},
|
||||||
|
{ok, Updated2} = logic_calendar:update_calendar(
|
||||||
|
UserId, Calendar#calendar.id, [{settings, Settings2}]),
|
||||||
|
?assertEqual(null, maps:get(<<"default_recurrence">>, Updated2#calendar.settings)).
|
||||||
|
|
||||||
|
test_update_settings_invalid_location() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Salon">>, <<"">>, manual),
|
||||||
|
?assertMatch({error, {invalid_settings, <<"default_location">>}},
|
||||||
|
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
|
||||||
|
{settings, #{<<"default_location">> => #{<<"address">> => <<"">>}}}
|
||||||
|
])),
|
||||||
|
?assertMatch({error, {invalid_settings, <<"default_location">>}},
|
||||||
|
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
|
||||||
|
{settings, #{<<"default_location">> => #{<<"address">> => <<"X">>, <<"lat">> => 1}}}
|
||||||
|
])),
|
||||||
|
?assertMatch({error, {invalid_settings, <<"default_duration_minutes">>}},
|
||||||
|
logic_calendar:update_calendar(UserId, Calendar#calendar.id, [
|
||||||
|
{settings, #{<<"default_duration_minutes">> => 0}}
|
||||||
|
])),
|
||||||
|
?assertEqual(#{},
|
||||||
|
begin
|
||||||
|
{ok, Same} = logic_calendar:get_calendar(UserId, Calendar#calendar.id),
|
||||||
|
Same#calendar.settings
|
||||||
|
end),
|
||||||
|
?assertMatch({error, {invalid_settings, <<"default_location">>}},
|
||||||
|
logic_calendar:normalize_settings(#{<<"default_location">> => <<"street">>})).
|
||||||
|
|
||||||
test_delete_calendar() ->
|
test_delete_calendar() ->
|
||||||
UserId = create_test_user(),
|
UserId = create_test_user(),
|
||||||
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
|
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
|
||||||
|
|||||||
Reference in New Issue
Block a user