feat(calendar): validate org defaults in calendar.settings
CI / test (push) Successful in 7m30s
CI / deploy-ift (push) Successful in 3m46s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Successful in 2m11s
CI / e2e-stage (push) Successful in 1m15s

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:
2026-07-29 22:50:31 +03:00
parent 4c480f1c43
commit 4986baf006
4 changed files with 191 additions and 19 deletions
+3
View File
@@ -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
View File
@@ -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() ->
+16 -2
View File
@@ -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) ->
+62
View File
@@ -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),