/ 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  }