food-market/tests/food-market.IntegrationTests/PinLoginTests.cs
nns 6b959ac59d
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
feat(pos): быстрый вход кассира по PIN (grant_type=pin) + lockout
- Сервер: 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>
2026-10-08 09:38:36 +05:00

122 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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