Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
- Сервер: Employee.PinHash (PBKDF2, миграция Phase36); POST
/api/organization/employees/{id}/pin — админ задаёт/снимает PIN (4–6 цифр,
уникален среди активных сотрудников на пересекающихся точках). Кастомный
OpenIddict-грант grant_type=pin (retail_point_id + pin): находит кассира,
привязанного к точке, по PIN и выдаёт токены. Брутфорс-защита — in-memory по
точке (5 неверных → блок 5 мин). Клиент-сидер досоздаёт gt:pin существующим
клиентам (без пересоздания). Пароль остаётся запасным.
- Касса: PIN-пад на зарегистрированном устройстве (маскированный ввод, 0–9/⌫/
Войти), переключение на вход по логину; AuthService.PinLoginAsync.
- Тесты: unit PinHasher (9), integration (3): вход по PIN выдаёт рабочий токен;
5 неверных → блок; дубль PIN на точке → 409. + скриншот PIN-пада в галерее.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
122 lines
6.7 KiB
C#
122 lines
6.7 KiB
C#
using System.Net;
|
||
using System.Net.Http.Json;
|
||
using System.Text.Json;
|
||
using FluentAssertions;
|
||
using foodmarket.IntegrationTests.Support;
|
||
using Xunit;
|
||
|
||
namespace foodmarket.IntegrationTests;
|
||
|
||
/// <summary>Быстрый вход кассира по PIN (grant_type=pin) на зарегистрированном
|
||
/// устройстве: PIN задаёт админ, касса входит по (точка + PIN); 5 неверных → блок.</summary>
|
||
[Collection(ApiCollection.Name)]
|
||
public class PinLoginTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public PinLoginTests(ApiFactory factory) => _factory = factory;
|
||
|
||
private async Task<HttpResponseMessage> PinLoginAsync(HttpClient client, string pointId, string pin)
|
||
=> await client.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
|
||
{
|
||
["grant_type"] = "pin", ["client_id"] = "food-market-web",
|
||
["retail_point_id"] = pointId, ["pin"] = pin,
|
||
["scope"] = "openid profile email roles api offline_access",
|
||
}));
|
||
|
||
[Fact]
|
||
public async Task Cashier_logs_in_by_pin_on_assigned_point()
|
||
{
|
||
var admin = new ApiActor(_factory.CreateClient());
|
||
await admin.SignupAndLoginAsync($"pin-{Guid.NewGuid():N}");
|
||
var refs = await admin.LoadRefsAsync();
|
||
var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
|
||
|
||
var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new
|
||
{
|
||
name = $"Касса-{Guid.NewGuid():N}",
|
||
permissions = new { retailSalesOperate = true, cashShiftOperate = true, productsView = true },
|
||
});
|
||
roleResp.EnsureSuccessStatusCode();
|
||
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
|
||
var email = $"pin-c-{Guid.NewGuid():N}@example.kz";
|
||
var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
|
||
{
|
||
lastName = "Кассиров", firstName = "Пётр", roleId, isActive = true, createAccount = true, email,
|
||
retailPointIds = new[] { point },
|
||
});
|
||
empResp.EnsureSuccessStatusCode();
|
||
var empId = (await empResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString();
|
||
|
||
// Админ задаёт PIN.
|
||
var setPin = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "4321" });
|
||
setPin.StatusCode.Should().Be(HttpStatusCode.OK);
|
||
|
||
// Вход по PIN (точка + PIN) → токен.
|
||
var anon = _factory.CreateClient();
|
||
using var ok = await PinLoginAsync(anon, point, "4321");
|
||
ok.StatusCode.Should().Be(HttpStatusCode.OK, await ok.Content.ReadAsStringAsync());
|
||
var token = (await ok.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString();
|
||
token.Should().NotBeNullOrEmpty();
|
||
|
||
// Токен рабочий: /api/me отдаёт точку кассира.
|
||
anon.DefaultRequestHeaders.Authorization = new("Bearer", token);
|
||
var me = await anon.GetFromJsonAsync<JsonElement>("/api/me");
|
||
me.GetProperty("retailPointIds").EnumerateArray().Select(x => x.GetString()).Should().Contain(point);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Wrong_pin_is_rejected_and_locks_after_five()
|
||
{
|
||
var admin = new ApiActor(_factory.CreateClient());
|
||
await admin.SignupAndLoginAsync($"pinlock-{Guid.NewGuid():N}");
|
||
var refs = await admin.LoadRefsAsync();
|
||
var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
|
||
var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new
|
||
{ name = $"Касса-{Guid.NewGuid():N}", permissions = new { cashShiftOperate = true } });
|
||
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
var email = $"pinlock-c-{Guid.NewGuid():N}@example.kz";
|
||
var empResp = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
|
||
{ lastName = "К", firstName = "П", roleId, isActive = true, createAccount = true, email, retailPointIds = new[] { point } });
|
||
var empId = (await empResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString();
|
||
(await admin.Http.PostAsJsonAsync($"/api/organization/employees/{empId}/pin", new { pin = "1111" })).EnsureSuccessStatusCode();
|
||
|
||
var anon = _factory.CreateClient();
|
||
// 5 неверных попыток.
|
||
for (int i = 0; i < 5; i++)
|
||
{
|
||
using var bad = await PinLoginAsync(anon, point, "0000");
|
||
bad.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||
}
|
||
// Теперь точка заблокирована: даже верный PIN не проходит.
|
||
using var locked = await PinLoginAsync(anon, point, "1111");
|
||
locked.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||
(await locked.Content.ReadAsStringAsync()).Should().Contain("pin_locked");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Duplicate_pin_on_same_point_is_rejected()
|
||
{
|
||
var admin = new ApiActor(_factory.CreateClient());
|
||
await admin.SignupAndLoginAsync($"pindup-{Guid.NewGuid():N}");
|
||
var refs = await admin.LoadRefsAsync();
|
||
var point = await admin.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
|
||
var roleResp = await admin.Http.PostAsJsonAsync("/api/organization/employee-roles", new
|
||
{ name = $"Касса-{Guid.NewGuid():N}", permissions = new { cashShiftOperate = true } });
|
||
var roleId = (await roleResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
|
||
async Task<string> MakeEmp(string ln)
|
||
{
|
||
var r = await admin.Http.PostAsJsonAsync("/api/organization/employees", new
|
||
{ lastName = ln, firstName = "П", roleId, isActive = true, createAccount = true, email = $"dup-{Guid.NewGuid():N}@example.kz", retailPointIds = new[] { point } });
|
||
r.EnsureSuccessStatusCode();
|
||
return (await r.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("employee").GetProperty("id").GetString()!;
|
||
}
|
||
var e1 = await MakeEmp("Первый");
|
||
var e2 = await MakeEmp("Второй");
|
||
(await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e1}/pin", new { pin = "2468" })).EnsureSuccessStatusCode();
|
||
using var dup = await admin.Http.PostAsJsonAsync($"/api/organization/employees/{e2}/pin", new { pin = "2468" });
|
||
dup.StatusCode.Should().Be(HttpStatusCode.Conflict);
|
||
}
|
||
}
|