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">>);
|
||||
{error, 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, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
|
||||
+110
-17
@@ -4,6 +4,7 @@
|
||||
-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]).
|
||||
-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]).
|
||||
|
||||
%% Создание календаря с политикой по умолчанию (manual)
|
||||
@@ -109,12 +110,16 @@ update_calendar(UserId, CalendarId, Updates) ->
|
||||
{ok, Calendar} ->
|
||||
case can_edit(UserId, Calendar) of
|
||||
true ->
|
||||
ValidUpdates = validate_updates(Updates),
|
||||
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
||||
case validate_updates(Updates) of
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
{ok, ValidUpdates2} ->
|
||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
||||
{ok, ValidUpdates} ->
|
||||
case gate_type_change(UserId, Calendar, ValidUpdates) of
|
||||
{error, _} = Err ->
|
||||
Err;
|
||||
{ok, ValidUpdates2} ->
|
||||
apply_calendar_update(CalendarId, Calendar, ValidUpdates2)
|
||||
end
|
||||
end;
|
||||
false ->
|
||||
{error, access_denied}
|
||||
@@ -225,24 +230,41 @@ can_edit(_UserId, #calendar{owner_id = _OwnerId}) ->
|
||||
can_edit(_, _) ->
|
||||
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) ->
|
||||
lists:filtermap(fun(U) ->
|
||||
case normalize_update(U) of
|
||||
false -> false;
|
||||
Norm ->
|
||||
case validate_update(Norm) of
|
||||
true -> {true, Norm};
|
||||
false -> false
|
||||
end
|
||||
end
|
||||
end, Updates).
|
||||
validate_updates(Updates, []).
|
||||
|
||||
validate_updates([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
validate_updates([U | Rest], Acc) ->
|
||||
case normalize_update(U) of
|
||||
false ->
|
||||
validate_updates(Rest, Acc);
|
||||
{error, _} = Err ->
|
||||
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, <<"commercial">>}) -> {type, commercial};
|
||||
normalize_update({type, personal}) -> {type, personal};
|
||||
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({<<"description">>, Value}) when is_binary(Value) -> {description, 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({color, 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({confirmation, Value}) ->
|
||||
case Value of
|
||||
@@ -272,6 +294,77 @@ validate_update({confirmation, Value}) ->
|
||||
end;
|
||||
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()}.
|
||||
admin_list_all() ->
|
||||
|
||||
@@ -62,7 +62,7 @@ test_update_calendar(Token, CalId) ->
|
||||
ct:pal(" OK").
|
||||
|
||||
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>>,
|
||||
Settings = #{
|
||||
<<"week_patterns">> => [
|
||||
@@ -73,12 +73,26 @@ test_update_calendar_settings(Token, CalId) ->
|
||||
#{<<"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}),
|
||||
?assertEqual(Settings, maps:get(<<"settings">>, Updated)),
|
||||
Got = api_test_runner:client_get(Path, Token),
|
||||
?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").
|
||||
|
||||
test_update_calendar_forbidden(OtherToken, CalId) ->
|
||||
|
||||
@@ -37,6 +37,8 @@ logic_calendar_test_() ->
|
||||
{"Update calendar test", fun test_update_calendar/0},
|
||||
{"Persist calendar settings (week_patterns)", fun test_update_settings/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},
|
||||
{"Access control test", fun test_access_control/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">>]}]),
|
||||
?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() ->
|
||||
UserId = create_test_user(),
|
||||
{ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),
|
||||
|
||||
Reference in New Issue
Block a user