/ GUNRPG.Tests / DeviceAuthClientTests.cs
DeviceAuthClientTests.cs
1 using System.Net; 2 using System.Text; 3 using System.Text.Json; 4 using GUNRPG.Application.Identity.Dtos; 5 using GUNRPG.ConsoleClient.Identity; 6 7 namespace GUNRPG.Tests; 8 9 public sealed class DeviceAuthClientTests 10 { 11 private static readonly JsonSerializerOptions s_json = new(JsonSerializerDefaults.Web); 12 private const string BaseUrl = "https://node.example.com"; 13 14 [Fact] 15 public async Task StartDeviceFlowAsync_DeserializesResponse() 16 { 17 var expected = new DeviceCodeResponse( 18 DeviceCode: "dc-123", 19 UserCode: "ABCD-EFGH", 20 VerificationUri: "https://node.example.com/auth/device/verify", 21 ExpiresInSeconds: 300, 22 PollIntervalSeconds: 0); 23 24 var handler = new FakeHandler(); 25 handler.Enqueue(HttpStatusCode.OK, JsonSerializer.Serialize(expected, s_json)); 26 using var http = new HttpClient(handler); 27 28 var client = new DeviceAuthClient(http, BaseUrl); 29 var result = await client.StartDeviceFlowAsync(); 30 31 Assert.Equal("dc-123", result.DeviceCode); 32 Assert.Equal("ABCD-EFGH", result.UserCode); 33 Assert.Equal(0, result.PollIntervalSeconds); 34 } 35 36 [Fact] 37 public async Task PollForTokenAsync_ReturnsTokens_WhenImmediatelyAuthorized() 38 { 39 var tokens = MakeTokenResponse(); 40 var deviceFlow = MakeDeviceFlow(); 41 var pollJson = JsonSerializer.Serialize( 42 new DevicePollResponse("authorized", tokens), s_json); 43 44 var handler = new FakeHandler(); 45 handler.Enqueue(HttpStatusCode.OK, pollJson); 46 using var http = new HttpClient(handler); 47 48 var client = new DeviceAuthClient(http, BaseUrl); 49 var result = await client.PollForTokenAsync(deviceFlow); 50 51 Assert.Equal("access-token", result.AccessToken); 52 Assert.Equal("refresh-token", result.RefreshToken); 53 } 54 55 [Fact] 56 public async Task PollForTokenAsync_Throws_WhenExpiredToken() 57 { 58 var deviceFlow = MakeDeviceFlow(); 59 var pendingJson = JsonSerializer.Serialize( 60 new DevicePollResponse("expired_token", null), s_json); 61 62 var handler = new FakeHandler(); 63 handler.Enqueue(HttpStatusCode.OK, pendingJson); 64 using var http = new HttpClient(handler); 65 66 var client = new DeviceAuthClient(http, BaseUrl); 67 68 await Assert.ThrowsAsync<InvalidOperationException>( 69 () => client.PollForTokenAsync(deviceFlow)); 70 } 71 72 [Fact] 73 public async Task PollForTokenAsync_Throws_WhenAccessDenied() 74 { 75 var deviceFlow = MakeDeviceFlow(); 76 var deniedJson = JsonSerializer.Serialize( 77 new DevicePollResponse("access_denied", null), s_json); 78 79 var handler = new FakeHandler(); 80 handler.Enqueue(HttpStatusCode.OK, deniedJson); 81 using var http = new HttpClient(handler); 82 83 var client = new DeviceAuthClient(http, BaseUrl); 84 85 await Assert.ThrowsAsync<InvalidOperationException>( 86 () => client.PollForTokenAsync(deviceFlow)); 87 } 88 89 [Fact] 90 public async Task PollForTokenAsync_EventuallyAuthorized_AfterPendingAndSlowDown() 91 { 92 // Verifies the loop continues through authorization_pending and slow_down, 93 // and resolves when the server returns "authorized". 94 var tokens = MakeTokenResponse(); 95 var deviceFlow = MakeDeviceFlow(); 96 97 var handler = new FakeHandler(); 98 handler.Enqueue(HttpStatusCode.OK, JsonSerializer.Serialize( 99 new DevicePollResponse("authorization_pending", null), s_json)); 100 handler.Enqueue(HttpStatusCode.OK, JsonSerializer.Serialize( 101 new DevicePollResponse("slow_down", null), s_json)); 102 handler.Enqueue(HttpStatusCode.OK, JsonSerializer.Serialize( 103 new DevicePollResponse("authorized", tokens), s_json)); 104 using var http = new HttpClient(handler); 105 106 var client = new DeviceAuthClient(http, BaseUrl); 107 var result = await client.PollForTokenAsync(deviceFlow); 108 109 Assert.Equal("access-token", result.AccessToken); 110 Assert.Equal(3, handler.Calls.Count); // pending + slow_down + authorized 111 } 112 113 [Fact] 114 public async Task PollForTokenAsync_Throws_WhenServerReturnsErrorStatus() 115 { 116 var deviceFlow = MakeDeviceFlow(); 117 var handler = new FakeHandler(); 118 handler.Enqueue(HttpStatusCode.InternalServerError, "Server error"); 119 using var http = new HttpClient(handler); 120 121 var client = new DeviceAuthClient(http, BaseUrl); 122 123 var ex = await Assert.ThrowsAsync<InvalidOperationException>( 124 () => client.PollForTokenAsync(deviceFlow)); 125 Assert.Contains("500", ex.Message); 126 Assert.Contains("Server error", ex.Message); 127 } 128 129 // ─── Helpers ───────────────────────────────────────────────────────────── 130 131 private static DeviceCodeResponse MakeDeviceFlow() => new( 132 DeviceCode: "dc-test", 133 UserCode: "TEST-CODE", 134 VerificationUri: "https://node.example.com/verify", 135 ExpiresInSeconds: 300, 136 PollIntervalSeconds: 0); // use 0 so Task.Delay is instant in tests 137 138 private static TokenResponse MakeTokenResponse() => new( 139 AccessToken: "access-token", 140 RefreshToken: "refresh-token", 141 AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddMinutes(15), 142 RefreshTokenExpiresAt: DateTimeOffset.UtcNow.AddDays(7)); 143 144 private sealed class FakeHandler : HttpMessageHandler 145 { 146 private readonly Queue<(HttpStatusCode Status, string? Body)> _queue = new(); 147 public List<(HttpMethod Method, string? Uri)> Calls { get; } = new(); 148 149 public void Enqueue(HttpStatusCode status, string? body = null) 150 => _queue.Enqueue((status, body)); 151 152 protected override async Task<HttpResponseMessage> SendAsync( 153 HttpRequestMessage request, CancellationToken ct) 154 { 155 Calls.Add((request.Method, request.RequestUri?.ToString())); 156 await Task.Yield(); 157 158 if (_queue.TryDequeue(out var entry)) 159 { 160 var msg = new HttpResponseMessage(entry.Status); 161 if (entry.Body is not null) 162 msg.Content = new StringContent(entry.Body, Encoding.UTF8, "application/json"); 163 return msg; 164 } 165 166 return new HttpResponseMessage(HttpStatusCode.InternalServerError); 167 } 168 } 169 }