using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class TwoFactorTests
{
private readonly ApiFactory _factory;
public TwoFactorTests(ApiFactory factory) => _factory = factory;
/// Enroll возвращает SharedKey + otpauth-URI; Verify с правильным
/// кодом включает 2FA; Status показывает enabled.
[Fact]
public async Task Enroll_verify_status_workflow()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"tfa-{Guid.NewGuid():N}");
// 1) Изначально 2FA выключен.
var status1 = await api.GetJsonAsync("/api/me/2fa/status");
status1.GetProperty("enabled").GetBoolean().Should().BeFalse();
// 2) Enroll → секрет.
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
enroll.EnsureSuccessStatusCode();
var enrollResp = await enroll.Content.ReadFromJsonAsync();
var sharedKey = enrollResp.GetProperty("sharedKey").GetString();
var uri = enrollResp.GetProperty("authenticatorUri").GetString();
sharedKey.Should().NotBeNullOrWhiteSpace();
uri.Should().Contain("otpauth://totp/");
uri.Should().Contain(sharedKey);
// 3) Сгенерировать TOTP-код по тому же RFC 6238 и Verify.
var code = GenerateTotp(sharedKey!);
var verify = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code });
verify.EnsureSuccessStatusCode();
// 4) Status теперь enabled.
var status2 = await api.GetJsonAsync("/api/me/2fa/status");
status2.GetProperty("enabled").GetBoolean().Should().BeTrue();
}
/// Wrong code → 400.
[Fact]
public async Task Verify_with_wrong_code_returns_400()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"tfa-wrong-{Guid.NewGuid():N}");
await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
using var r = await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = "000000" });
((int)r.StatusCode).Should().Be(400);
}
/// После включения 2FA password-grant без otp_code → invalid_grant
/// с описанием "2fa_required". С правильным otp_code — token выдаётся.
[Fact]
public async Task Token_endpoint_requires_otp_when_2fa_enabled()
{
var api = new ApiActor(_factory.CreateClient());
var (email, password) = await api.SignupAndLoginAsync($"tfa-tok-{Guid.NewGuid():N}");
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
var enrollResp = await enroll.Content.ReadFromJsonAsync();
var sharedKey = enrollResp.GetProperty("sharedKey").GetString();
await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) });
// Без otp_code → invalid_grant.
using var noOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
((int)noOtp.StatusCode).Should().Be(400);
var noOtpBody = await noOtp.Content.ReadAsStringAsync();
noOtpBody.Should().Contain("2fa_required");
// С правильным otp_code → success.
// TOTP-step 30 секунд; в тестах потенциально надо ждать новый шаг —
// используем тот же код, что прошёл при verify (в пределах окна).
using var withOtp = await api.Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
["otp_code"] = GenerateTotp(sharedKey!),
}));
withOtp.IsSuccessStatusCode.Should().BeTrue(
$"token c otp_code должен пройти: {(int)withOtp.StatusCode} {await withOtp.Content.ReadAsStringAsync()}");
}
[Fact]
public async Task Disable_requires_code()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"tfa-dis-{Guid.NewGuid():N}");
var enroll = await api.Http.PostAsJsonAsync("/api/me/2fa/enroll", new { });
var sharedKey = (await enroll.Content.ReadFromJsonAsync()).GetProperty("sharedKey").GetString();
await api.Http.PostAsJsonAsync("/api/me/2fa/verify", new { code = GenerateTotp(sharedKey!) });
// Disable без code → 400.
using var bad = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = (string?)null });
((int)bad.StatusCode).Should().Be(400);
// Disable с code → 200.
using var ok = await api.Http.PostAsJsonAsync("/api/me/2fa/disable", new { code = GenerateTotp(sharedKey!) });
ok.IsSuccessStatusCode.Should().BeTrue();
var status = await api.GetJsonAsync("/api/me/2fa/status");
status.GetProperty("enabled").GetBoolean().Should().BeFalse();
}
/// Generate RFC 6238 TOTP for given base32-encoded shared key.
/// 30-second time step, 6 digits, SHA1 (default for AuthenticatorTokenProvider).
private static string GenerateTotp(string base32Key)
{
var key = FromBase32(base32Key);
var counter = (long)Math.Floor((DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds / 30);
var counterBytes = BitConverter.GetBytes(counter);
if (BitConverter.IsLittleEndian) Array.Reverse(counterBytes);
using var hmac = new HMACSHA1(key);
var hash = hmac.ComputeHash(counterBytes);
var offset = hash[^1] & 0x0F;
var binary = ((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
var code = binary % 1_000_000;
return code.ToString("D6");
}
private static byte[] FromBase32(string s)
{
const string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
s = s.Trim().TrimEnd('=').ToUpperInvariant();
var bytes = new List();
int buf = 0, bits = 0;
foreach (var c in s)
{
var v = alphabet.IndexOf(c);
if (v < 0) continue;
buf = (buf << 5) | v;
bits += 5;
if (bits >= 8)
{
bits -= 8;
bytes.Add((byte)((buf >> bits) & 0xFF));
}
}
return bytes.ToArray();
}
}