SocialLoginHandlerTests.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. using Application.Abstractions.Authentication;
  2. using Application.Abstractions.Cache;
  3. using Application.Abstractions.Crypto;
  4. using Microsoft.EntityFrameworkCore;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Microsoft.Extensions.Options;
  7. using SharedKernel;
  8. using SocialLoginCommand = Application.Features.Api.Auth.SocialLogin.Command;
  9. using SocialLoginHandler = Application.Features.Api.Auth.SocialLogin.Handler;
  10. namespace Application.Tests;
  11. [TestClass]
  12. public sealed class SocialLoginHandlerTests
  13. {
  14. private sealed class FakeSocialAuthProvider(string provider, SocialUserInfo? userInfo) : ISocialAuthProvider
  15. {
  16. public string Provider => provider;
  17. public string GetAuthorizationUrl(string clientId, string redirectUri, string state) => $"https://fake/{provider}?state={state}";
  18. public Task<SocialTokens?> ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct)
  19. {
  20. return Task.FromResult<SocialTokens?>(new SocialTokens("fake-access-token", "fake-refresh-token", DateTime.UtcNow.AddHours(1), ["account_email"]));
  21. }
  22. public Task<SocialUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct) => Task.FromResult(userInfo);
  23. }
  24. private sealed class FakeJwtTokenProvider : IJwtTokenProvider
  25. {
  26. public string CreateAccessToken(int memberID, string email, string? name) => $"jwt-{memberID}";
  27. public string CreateRefreshToken() => $"rt-{Guid.NewGuid():N}";
  28. public string CreateOAuth2AccessToken(int applicationID, string clientID, int ownerMemberID, IEnumerable<string> scopes, TimeSpan ttl) => "oauth2";
  29. }
  30. private sealed class FakeFieldEncryptor : IFieldEncryptor
  31. {
  32. public int CurrentKeyVersion => 1;
  33. public string Encrypt(string plaintext) => $"enc:{plaintext}";
  34. public string Decrypt(string ciphertextBase64, int keyVersion) => ciphertextBase64.StartsWith("enc:") ? ciphertextBase64[4..] : ciphertextBase64;
  35. public string Hash(string plaintext) => plaintext;
  36. }
  37. private sealed class FakeCacheService : ICacheService
  38. {
  39. public Task<T?> GetAsync<T>(string key, CancellationToken ct = default) => Task.FromResult<T?>(default);
  40. public Task SetAsync<T>(string key, T value, CancellationToken ct = default) => Task.CompletedTask;
  41. public Task SetAsync<T>(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask;
  42. public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask;
  43. public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask;
  44. public Task<bool> TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true);
  45. }
  46. private static SocialLoginHandler CreateHandler(Infrastructure.Persistence.AppDbContext db, SocialUserInfo? userInfo, string provider = "Kakao")
  47. {
  48. var options = Options.Create(new AppSettings
  49. {
  50. JWT = new AppSettings.JwtSection
  51. {
  52. AccessTokenExpiration = 30,
  53. RefreshTokenExpiration = 7
  54. },
  55. OAuth = new AppSettings.OAuthSection
  56. {
  57. Naver = new AppSettings.OAuthSection.OAuthProviderSection
  58. {
  59. ClientId = "naver-client",
  60. ClientSecret = "naver-secret",
  61. RedirectUri = "https://localhost:3000/login/naver/callback"
  62. },
  63. Kakao = new AppSettings.OAuthSection.OAuthProviderSection
  64. {
  65. ClientId = "kakao-rest-key",
  66. ClientSecret = "",
  67. RedirectUri = "https://localhost:3000/login/kakao/callback"
  68. }
  69. }
  70. });
  71. return new SocialLoginHandler(
  72. db,
  73. new FakeJwtTokenProvider(),
  74. [new FakeSocialAuthProvider(provider, userInfo)],
  75. new FakeFieldEncryptor(),
  76. options,
  77. NullLogger<SocialLoginHandler>.Instance,
  78. new FakeCacheService()
  79. );
  80. }
  81. [TestMethod]
  82. public async Task KakaoEmailNotProvided_Fails_EmailRequired()
  83. {
  84. // 기대 행위: 카카오 이메일 선택동의 미동의(Email null) 시 Auth.EmailRequired 로 실패해
  85. // 프론트가 이메일 보완 화면으로 유도할 수 있어야 한다 (d5 §③ 정책 4).
  86. using var db = TestDb.Create();
  87. var handler = CreateHandler(db, new SocialUserInfo("987654321", null, false, "미동의개미", null));
  88. var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default);
  89. Assert.IsFalse(result.IsSuccess);
  90. Assert.AreEqual("Auth.EmailRequired", result.Error.Code);
  91. }
  92. [TestMethod]
  93. public async Task ExistingMemberWithoutLink_Fails_AccountExists_NoAutoLink()
  94. {
  95. // 기대 행위: 같은 이메일의 기존 계정이 있어도 자동 연결하지 않고 Auth.AccountExists 로 실패한다 (d0 §5-⑦ 자동 계정연결 금지).
  96. using var db = TestDb.Create();
  97. var memberID = await TestDb.CreateMemberAsync(db);
  98. var email = (await db.Member.SingleAsync(c => c.ID == memberID)).Email;
  99. var handler = CreateHandler(db, new SocialUserInfo("11122233", email, true, "개미", null));
  100. var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default);
  101. Assert.IsFalse(result.IsSuccess);
  102. Assert.AreEqual("Auth.AccountExists", result.Error.Code);
  103. using var verify = TestDb.Create();
  104. Assert.IsFalse(await verify.MemberOAuthToken.AnyAsync(c => c.MemberID == memberID), "자동 계정연결이 일어나면 안 된다");
  105. }
  106. [TestMethod]
  107. public async Task NewEmail_AutoSignup_CreatesMemberAndOAuthLink()
  108. {
  109. // 기대 행위: 미가입 이메일은 자동가입 + MemberOAuthToken 생성(암호화 저장) 후 JWT 를 발급한다.
  110. // 검증된 이메일(카카오 is_email_verified)만 이메일 인증 완료 처리한다.
  111. using var db = TestDb.Create();
  112. var email = $"social-{Guid.NewGuid():N}@antooza.test";
  113. var handler = CreateHandler(db, new SocialUserInfo("55566677", email, true, "신규개미", null));
  114. var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code", null, null, "127.0.0.1", "test-agent"), default);
  115. Assert.IsTrue(result.IsSuccess, $"자동가입 로그인이 성공해야 한다: {result.Error?.Code}");
  116. Assert.IsFalse(string.IsNullOrEmpty(result.Value.AccessToken));
  117. using var verify = TestDb.Create();
  118. var member = await verify.Member.SingleAsync(c => c.Email == email);
  119. Assert.IsTrue(member.IsEmailVerified, "검증된 이메일은 인증 완료 처리된다");
  120. var link = await verify.MemberOAuthToken.SingleAsync(c => c.MemberID == member.ID);
  121. Assert.AreEqual("Kakao", link.Provider);
  122. Assert.AreEqual("enc:fake-access-token", link.AccessTokenEnc, "신규 저장분은 IFieldEncryptor 로 암호화되어야 한다");
  123. Assert.IsTrue(await verify.RefreshToken.AnyAsync(c => c.MemberID == member.ID), "refresh token 이 저장되어야 한다");
  124. }
  125. }