using Application.Abstractions.Authentication; using Application.Abstractions.Cache; using Application.Abstractions.Crypto; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using SharedKernel; using SocialLoginCommand = Application.Features.Api.Auth.SocialLogin.Command; using SocialLoginHandler = Application.Features.Api.Auth.SocialLogin.Handler; namespace Application.Tests; [TestClass] public sealed class SocialLoginHandlerTests { private sealed class FakeSocialAuthProvider(string provider, SocialUserInfo? userInfo) : ISocialAuthProvider { public string Provider => provider; public string GetAuthorizationUrl(string clientId, string redirectUri, string state) => $"https://fake/{provider}?state={state}"; public Task ExchangeCodeAsync(string code, string redirectUri, string? state, string clientId, string? clientSecret, CancellationToken ct) { return Task.FromResult(new SocialTokens("fake-access-token", "fake-refresh-token", DateTime.UtcNow.AddHours(1), ["account_email"])); } public Task GetUserInfoAsync(string accessToken, CancellationToken ct) => Task.FromResult(userInfo); } private sealed class FakeJwtTokenProvider : IJwtTokenProvider { public string CreateAccessToken(int memberID, string email, string? name) => $"jwt-{memberID}"; public string CreateRefreshToken() => $"rt-{Guid.NewGuid():N}"; public string CreateOAuth2AccessToken(int applicationID, string clientID, int ownerMemberID, IEnumerable scopes, TimeSpan ttl) => "oauth2"; } private sealed class FakeFieldEncryptor : IFieldEncryptor { public int CurrentKeyVersion => 1; public string Encrypt(string plaintext) => $"enc:{plaintext}"; public string Decrypt(string ciphertextBase64, int keyVersion) => ciphertextBase64.StartsWith("enc:") ? ciphertextBase64[4..] : ciphertextBase64; public string Hash(string plaintext) => plaintext; } private sealed class FakeCacheService : ICacheService { public Task GetAsync(string key, CancellationToken ct = default) => Task.FromResult(default); public Task SetAsync(string key, T value, CancellationToken ct = default) => Task.CompletedTask; public Task SetAsync(string key, T value, TimeSpan expiry, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveAsync(string key, CancellationToken ct = default) => Task.CompletedTask; public Task RemoveByPrefixAsync(string prefix, CancellationToken ct = default) => Task.CompletedTask; public Task TryLockAsync(string key, TimeSpan expiry, CancellationToken ct = default) => Task.FromResult(true); } private static SocialLoginHandler CreateHandler(Infrastructure.Persistence.AppDbContext db, SocialUserInfo? userInfo, string provider = "Kakao") { var options = Options.Create(new AppSettings { JWT = new AppSettings.JwtSection { AccessTokenExpiration = 30, RefreshTokenExpiration = 7 }, OAuth = new AppSettings.OAuthSection { Naver = new AppSettings.OAuthSection.OAuthProviderSection { ClientId = "naver-client", ClientSecret = "naver-secret", RedirectUri = "https://localhost:3000/login/naver/callback" }, Kakao = new AppSettings.OAuthSection.OAuthProviderSection { ClientId = "kakao-rest-key", ClientSecret = "", RedirectUri = "https://localhost:3000/login/kakao/callback" } } }); return new SocialLoginHandler( db, new FakeJwtTokenProvider(), [new FakeSocialAuthProvider(provider, userInfo)], new FakeFieldEncryptor(), options, NullLogger.Instance, new FakeCacheService() ); } [TestMethod] public async Task KakaoEmailNotProvided_Fails_EmailRequired() { // 기대 행위: 카카오 이메일 선택동의 미동의(Email null) 시 Auth.EmailRequired 로 실패해 // 프론트가 이메일 보완 화면으로 유도할 수 있어야 한다 (d5 §③ 정책 4). using var db = TestDb.Create(); var handler = CreateHandler(db, new SocialUserInfo("987654321", null, false, "미동의개미", null)); var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("Auth.EmailRequired", result.Error.Code); } [TestMethod] public async Task ExistingMemberWithoutLink_Fails_AccountExists_NoAutoLink() { // 기대 행위: 같은 이메일의 기존 계정이 있어도 자동 연결하지 않고 Auth.AccountExists 로 실패한다 (d0 §5-⑦ 자동 계정연결 금지). using var db = TestDb.Create(); var memberID = await TestDb.CreateMemberAsync(db); var email = (await db.Member.SingleAsync(c => c.ID == memberID)).Email; var handler = CreateHandler(db, new SocialUserInfo("11122233", email, true, "개미", null)); var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code"), default); Assert.IsFalse(result.IsSuccess); Assert.AreEqual("Auth.AccountExists", result.Error.Code); using var verify = TestDb.Create(); Assert.IsFalse(await verify.MemberOAuthToken.AnyAsync(c => c.MemberID == memberID), "자동 계정연결이 일어나면 안 된다"); } [TestMethod] public async Task NewEmail_AutoSignup_CreatesMemberAndOAuthLink() { // 기대 행위: 미가입 이메일은 자동가입 + MemberOAuthToken 생성(암호화 저장) 후 JWT 를 발급한다. // 검증된 이메일(카카오 is_email_verified)만 이메일 인증 완료 처리한다. using var db = TestDb.Create(); var email = $"social-{Guid.NewGuid():N}@antooza.test"; var handler = CreateHandler(db, new SocialUserInfo("55566677", email, true, "신규개미", null)); var result = await handler.Handle(new SocialLoginCommand("kakao", "auth-code", null, null, "127.0.0.1", "test-agent"), default); Assert.IsTrue(result.IsSuccess, $"자동가입 로그인이 성공해야 한다: {result.Error?.Code}"); Assert.IsFalse(string.IsNullOrEmpty(result.Value.AccessToken)); using var verify = TestDb.Create(); var member = await verify.Member.SingleAsync(c => c.Email == email); Assert.IsTrue(member.IsEmailVerified, "검증된 이메일은 인증 완료 처리된다"); var link = await verify.MemberOAuthToken.SingleAsync(c => c.MemberID == member.ID); Assert.AreEqual("Kakao", link.Provider); Assert.AreEqual("enc:fake-access-token", link.AccessTokenEnc, "신규 저장분은 IFieldEncryptor 로 암호화되어야 한다"); Assert.IsTrue(await verify.RefreshToken.AnyAsync(c => c.MemberID == member.ID), "refresh token 이 저장되어야 한다"); } }