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:
nns 2026-05-08 01:05:48 +05:00
parent bcf81c57ee
commit 46877cc134
6 changed files with 69 additions and 21 deletions

View file

@ -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)

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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}\"}}";

View 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;
}
}