fix(phone): серверная KZ-ФЛК на всех endpoint'ах принимающих phone
Logic gap из e2e-отчёта: SuperAdmin /organizations принимал любой текст в Phone — серверной валидации ФЛК не было (только в /api/auth/signup). Это позволяло сохранить «abc» в Organization.Phone и невалидные номера для контрагентов и сотрудников. — Application/Common/PhoneNormalization.cs (новый): TryNormalizeKz + IsValidOrEmpty. Принимает любое форматирование, ведущая «8» → «7»; валидно: 11 цифр, начинается с «77» (мобильный код KZ). — SuperAdminOrganizationsController.Create/Update: 400 если phone не парсится; в БД пишется нормализованная форма «+77001234567». — CounterpartiesController.Create/Update: то же. Apply() нормализует. — EmployeesController.Create/Update: то же. — SuperAdminEmployeesController.Create/Update: то же. — AuthSignupController: убран локальный NormalizeKzPhone, используется shared. Сообщение об ошибке унифицировано. Defense-in-depth к фронтовой валидации (PhoneInput / validatePhone). Незаполненный phone остаётся валидным для опциональных полей — контроллер сам решает требовать или нет.
This commit is contained in:
parent
bcf81c57ee
commit
46877cc134
|
|
@ -31,19 +31,6 @@ public AuthSignupController(AppDbContext db, UserManager<User> userMgr)
|
||||||
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
|
public record SignupInput(string Email, string Password, string OrganizationName, string Phone, string? Plan);
|
||||||
public record SignupResult(Guid OrganizationId, string Email);
|
public record SignupResult(Guid OrganizationId, string Email);
|
||||||
|
|
||||||
/// <summary>Нормализация и проверка телефона Казахстана. Принимаем любое
|
|
||||||
/// форматирование (пробелы, скобки, +, дефисы), оставляем только цифры,
|
|
||||||
/// ведущая «8» переписывается в «7». Валидно: ровно 11 цифр, начинается
|
|
||||||
/// с «77» — мобильный код KZ. «79…» (РФ) и прочие отвергаем.</summary>
|
|
||||||
private static string? NormalizeKzPhone(string? raw)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
|
||||||
var digits = new string(raw.Where(char.IsDigit).ToArray());
|
|
||||||
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
|
|
||||||
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
|
|
||||||
return "+" + digits;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("signup")]
|
[HttpPost("signup")]
|
||||||
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
|
public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -52,9 +39,9 @@ public async Task<ActionResult<SignupResult>> Signup([FromBody] SignupInput inpu
|
||||||
return BadRequest(new { error = "Email, пароль и название обязательны." });
|
return BadRequest(new { error = "Email, пароль и название обязательны." });
|
||||||
if (input.Password.Length < 8)
|
if (input.Password.Length < 8)
|
||||||
return BadRequest(new { error = "Пароль минимум 8 символов." });
|
return BadRequest(new { error = "Пароль минимум 8 символов." });
|
||||||
var normalizedPhone = NormalizeKzPhone(input.Phone);
|
var normalizedPhone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
|
||||||
if (normalizedPhone is null)
|
if (normalizedPhone is null)
|
||||||
return BadRequest(new { error = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67" });
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
|
|
||||||
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
var existing = await _userMgr.FindByEmailAsync(input.Email);
|
||||||
if (existing is not null)
|
if (existing is not null)
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
||||||
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
||||||
var e = Apply(new Counterparty(), input);
|
var e = Apply(new Counterparty(), input);
|
||||||
_db.Counterparties.Add(e);
|
_db.Counterparties.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
@ -81,6 +82,7 @@ public async Task<ActionResult<CounterpartyDto>> Create([FromBody] CounterpartyI
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (NormalizePhoneOrError(input.Phone) is { } err) return BadRequest(new { error = err });
|
||||||
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.Counterparties.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
|
|
@ -88,6 +90,14 @@ public async Task<IActionResult> Update(Guid id, [FromBody] CounterpartyInput in
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? NormalizePhoneOrError(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
return foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(raw)
|
||||||
|
? null
|
||||||
|
: foodmarket.Application.Common.PhoneNormalization.ErrorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -108,7 +118,7 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
||||||
e.TaxNumber = i.TaxNumber;
|
e.TaxNumber = i.TaxNumber;
|
||||||
e.CountryId = i.CountryId;
|
e.CountryId = i.CountryId;
|
||||||
e.Address = i.Address;
|
e.Address = i.Address;
|
||||||
e.Phone = i.Phone;
|
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(i.Phone);
|
||||||
e.Email = i.Email;
|
e.Email = i.Email;
|
||||||
e.BankName = i.BankName;
|
e.BankName = i.BankName;
|
||||||
e.BankAccount = i.BankAccount;
|
e.BankAccount = i.BankAccount;
|
||||||
|
|
|
||||||
|
|
@ -217,7 +217,10 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
|
||||||
e.MiddleName = input.MiddleName;
|
e.MiddleName = input.MiddleName;
|
||||||
e.Position = input.Position;
|
e.Position = input.Position;
|
||||||
e.Email = input.Email;
|
e.Email = input.Email;
|
||||||
e.Phone = input.Phone;
|
if (!string.IsNullOrWhiteSpace(input.Phone)
|
||||||
|
&& !foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
|
||||||
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
|
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
|
||||||
e.Salary = input.Salary;
|
e.Salary = input.Salary;
|
||||||
e.TaxNumber = input.TaxNumber;
|
e.TaxNumber = input.TaxNumber;
|
||||||
e.Description = input.Description;
|
e.Description = input.Description;
|
||||||
|
|
|
||||||
|
|
@ -168,11 +168,15 @@ public async Task<ActionResult<EmployeeDetail>> Create(Guid orgId, [FromBody] Cr
|
||||||
newUserId = u.Id;
|
newUserId = u.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Phone))
|
||||||
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
|
|
||||||
var emp = new Employee
|
var emp = new Employee
|
||||||
{
|
{
|
||||||
OrganizationId = orgId, UserId = newUserId,
|
OrganizationId = orgId, UserId = newUserId,
|
||||||
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
LastName = input.LastName, FirstName = input.FirstName, MiddleName = input.MiddleName,
|
||||||
Position = input.Position, Email = input.Email, Phone = input.Phone,
|
Position = input.Position, Email = input.Email,
|
||||||
|
Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone),
|
||||||
RoleId = input.RoleId, IsActive = input.IsActive,
|
RoleId = input.RoleId, IsActive = input.IsActive,
|
||||||
};
|
};
|
||||||
foreach (var rp in input.RetailPointIds ?? [])
|
foreach (var rp in input.RetailPointIds ?? [])
|
||||||
|
|
@ -206,7 +210,9 @@ public async Task<IActionResult> Update(Guid orgId, Guid id, [FromBody] UpdateIn
|
||||||
e.MiddleName = input.Employee.MiddleName;
|
e.MiddleName = input.Employee.MiddleName;
|
||||||
e.Position = input.Employee.Position;
|
e.Position = input.Employee.Position;
|
||||||
e.Email = input.Employee.Email;
|
e.Email = input.Employee.Email;
|
||||||
e.Phone = input.Employee.Phone;
|
if (!foodmarket.Application.Common.PhoneNormalization.IsValidOrEmpty(input.Employee.Phone))
|
||||||
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
|
e.Phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Employee.Phone);
|
||||||
e.Salary = input.Employee.Salary;
|
e.Salary = input.Employee.Salary;
|
||||||
e.TaxNumber = input.Employee.TaxNumber;
|
e.TaxNumber = input.Employee.TaxNumber;
|
||||||
e.Description = input.Employee.Description;
|
e.Description = input.Employee.Description;
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,15 @@ public async Task<ActionResult<OrgDetail>> Get(Guid id, CancellationToken ct)
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
|
public async Task<ActionResult<CreateOrgResult>> Create([FromBody] CreateOrgRequest input, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Org.Phone);
|
||||||
|
if (!string.IsNullOrEmpty(input.Org.Phone) && phone is null)
|
||||||
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
|
|
||||||
var org = new Organization
|
var org = new Organization
|
||||||
{
|
{
|
||||||
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
Name = input.Org.Name, CountryCode = input.Org.CountryCode,
|
||||||
Bin = input.Org.Bin, Address = input.Org.Address,
|
Bin = input.Org.Bin, Address = input.Org.Address,
|
||||||
Phone = input.Org.Phone, Email = input.Org.Email,
|
Phone = phone, Email = input.Org.Email,
|
||||||
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
DefaultCurrencyId = input.Org.DefaultCurrencyId,
|
||||||
};
|
};
|
||||||
_db.Organizations.Add(org);
|
_db.Organizations.Add(org);
|
||||||
|
|
@ -147,10 +151,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] OrgInput input, Canc
|
||||||
{
|
{
|
||||||
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (o is null) return NotFound();
|
if (o is null) return NotFound();
|
||||||
|
var phone = foodmarket.Application.Common.PhoneNormalization.TryNormalizeKz(input.Phone);
|
||||||
|
if (!string.IsNullOrEmpty(input.Phone) && phone is null)
|
||||||
|
return BadRequest(new { error = foodmarket.Application.Common.PhoneNormalization.ErrorMessage });
|
||||||
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
var before = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
o.Name = input.Name; o.CountryCode = input.CountryCode;
|
||||||
o.Bin = input.Bin; o.Address = input.Address;
|
o.Bin = input.Bin; o.Address = input.Address;
|
||||||
o.Phone = input.Phone; o.Email = input.Email;
|
o.Phone = phone; o.Email = input.Email;
|
||||||
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
o.DefaultCurrencyId = input.DefaultCurrencyId;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
var after = $"{{\"name\":\"{o.Name}\",\"bin\":\"{o.Bin}\"}}";
|
||||||
|
|
|
||||||
35
src/food-market.application/Common/PhoneNormalization.cs
Normal file
35
src/food-market.application/Common/PhoneNormalization.cs
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
namespace foodmarket.Application.Common;
|
||||||
|
|
||||||
|
/// <summary>Серверная валидация и нормализация телефонов Казахстана.
|
||||||
|
/// Принимает любое форматирование (+7 700 123-45-67, 8 (700) 1234567, ...),
|
||||||
|
/// оставляет только цифры, ведущая «8» переписывается в «7».
|
||||||
|
/// Валидно: ровно 11 цифр, начинается с «77» — мобильный код KZ.
|
||||||
|
/// «79…» (РФ) и прочие отвергаются.
|
||||||
|
///
|
||||||
|
/// Используется на всех endpoint'ах, принимающих phone — defense-in-depth
|
||||||
|
/// к фронт-валидации (PhoneInput / validatePhone).</summary>
|
||||||
|
public static class PhoneNormalization
|
||||||
|
{
|
||||||
|
public const string ErrorMessage = "Введите корректный номер Казахстана. Пример: +7 700 123 45 67";
|
||||||
|
|
||||||
|
/// <summary>Возвращает нормализованный номер вида "+77001234567" если phone валиден,
|
||||||
|
/// null если не валиден. Пустой ввод возвращает null без ошибки —
|
||||||
|
/// проверку «обязательно ли» делает вызывающая сторона.</summary>
|
||||||
|
public static string? TryNormalizeKz(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
var digits = new string(raw.Where(char.IsDigit).ToArray());
|
||||||
|
if (digits.Length == 11 && digits[0] == '8') digits = "7" + digits[1..];
|
||||||
|
if (digits.Length != 11 || !digits.StartsWith("77")) return null;
|
||||||
|
return "+" + digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>True если phone валиден или null/пусто (опциональное поле).
|
||||||
|
/// False — только если непустой ввод не парсится. Удобно для контроллеров,
|
||||||
|
/// которые принимают phone как опциональный.</summary>
|
||||||
|
public static bool IsValidOrEmpty(string? raw)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw)) return true;
|
||||||
|
return TryNormalizeKz(raw) is not null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue