From f2a32c3036d8147625f8f1b62cab3c064be6c386 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:14 +0200 Subject: [PATCH 01/12] MT-23076: add ApiTokenExpiration type with tri-state expires_at serialization --- .../ApiTokenExpirationJsonConverter.cs | 40 ++++++++++++++ .../ApiTokens/Models/ApiTokenExpiration.cs | 53 +++++++++++++++++++ src/Mailtrap.Abstractions/GlobalUsings.cs | 1 + 3 files changed, 94 insertions(+) create mode 100644 src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs create mode 100644 src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenExpiration.cs diff --git a/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs b/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs new file mode 100644 index 00000000..4f71d3c4 --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs @@ -0,0 +1,40 @@ +namespace Mailtrap.ApiTokens.Converters; + + +/// +/// Custom JSON converter to be used for .
+/// Writes JSON for +/// and the ISO 8601 date-time string otherwise. +///
+internal sealed class ApiTokenExpirationJsonConverter : JsonConverter +{ + public override bool HandleNull => true; + + + public override ApiTokenExpiration? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return reader.TokenType switch + { + JsonTokenType.Null => ApiTokenExpiration.Never, + JsonTokenType.String => reader.TryGetDateTimeOffset(out var date) + ? ApiTokenExpiration.At(date) + : throw new JsonException($"Cannot convert value '{reader.GetString()}' to {nameof(ApiTokenExpiration)}."), + _ => throw new JsonException($"Unexpected JSON token {reader.TokenType} for {nameof(ApiTokenExpiration)}.") + }; + } + + public override void Write(Utf8JsonWriter writer, ApiTokenExpiration value, JsonSerializerOptions options) + { + Ensure.NotNull(writer, nameof(writer)); + Ensure.NotNull(value, nameof(value)); + + if (value.Value.HasValue) + { + writer.WriteStringValue(value.Value.Value); + } + else + { + writer.WriteNullValue(); + } + } +} diff --git a/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenExpiration.cs b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenExpiration.cs new file mode 100644 index 00000000..6a72169b --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Models/ApiTokenExpiration.cs @@ -0,0 +1,53 @@ +namespace Mailtrap.ApiTokens.Models; + + +/// +/// Represents an explicit API token expiration, serialized as an ISO 8601 date-time.
+/// Leave the request property unset to omit the value and get the server default +/// (a 1-year default is being rolled out).
+/// Use for a token that never expires.
+/// Past or more-than-5-years-ahead values are rejected with 422. +///
+[JsonConverter(typeof(ApiTokenExpirationJsonConverter))] +public sealed record ApiTokenExpiration +{ + /// + /// Gets the expiration for a token that never expires. + /// + /// + /// + /// Expiration for a token that never expires. Serialized as JSON . + /// + public static ApiTokenExpiration Never { get; } = new((DateTimeOffset?)null); + + + /// + /// Creates an expiration at the provided date and time. + /// + /// + /// + /// Date and time when the token expires. + /// + /// + /// + /// Expiration at the provided date and time. Serialized as an ISO 8601 date-time string. + /// + public static ApiTokenExpiration At(DateTimeOffset value) => new(value); + + + /// + /// Gets the expiration date and time, + /// or for a token that never expires. + /// + /// + /// + /// Expiration date and time, or for a token that never expires. + /// + internal DateTimeOffset? Value { get; } + + + private ApiTokenExpiration(DateTimeOffset? value) + { + Value = value; + } +} diff --git a/src/Mailtrap.Abstractions/GlobalUsings.cs b/src/Mailtrap.Abstractions/GlobalUsings.cs index ed66356e..c63c8df5 100644 --- a/src/Mailtrap.Abstractions/GlobalUsings.cs +++ b/src/Mailtrap.Abstractions/GlobalUsings.cs @@ -12,6 +12,7 @@ global using Mailtrap.Accounts; global using Mailtrap.Accounts.Models; global using Mailtrap.ApiTokens; +global using Mailtrap.ApiTokens.Converters; global using Mailtrap.ApiTokens.Models; global using Mailtrap.ApiTokens.Requests; global using Mailtrap.ApiTokens.Responses; From d13e416c344378a56efd3fbea531765fd544c608 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:23 +0200 Subject: [PATCH 02/12] MT-23076: add optional expires_at to create api token request --- .../ApiTokens/Requests/CreateApiTokenRequest.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs b/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs index 2f332166..9f437a66 100644 --- a/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs +++ b/src/Mailtrap.Abstractions/ApiTokens/Requests/CreateApiTokenRequest.cs @@ -18,6 +18,20 @@ public sealed record CreateApiTokenRequest : IValidatable [JsonRequired] public string Name { get; set; } = string.Empty; + /// + /// Gets or sets the optional token expiration. + /// + /// + /// + /// Optional token expiration as an ISO 8601 date-time.
+ /// Omit for the server default (a 1-year default is being rolled out).
+ /// Use for a token that never expires.
+ /// Past or more-than-5-years-ahead values are rejected with 422. + ///
+ [JsonPropertyName("expires_at")] + [JsonPropertyOrder(2)] + public ApiTokenExpiration? ExpiresAt { get; set; } + /// /// Gets the resource accesses to grant to the API token. /// @@ -26,7 +40,7 @@ public sealed record CreateApiTokenRequest : IValidatable /// Collection of resource accesses. /// [JsonPropertyName("resources")] - [JsonPropertyOrder(2)] + [JsonPropertyOrder(3)] [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] public IList Resources { get; } = []; From b56033afe75f096d1dd1c431135ea0d876a20862 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:23 +0200 Subject: [PATCH 03/12] MT-23076: add reset api token overload with optional expiration --- .../ApiTokens/IApiTokenResource.cs | 23 +++++++++++++++++++ .../Requests/ResetApiTokenRequest.cs | 22 ++++++++++++++++++ src/Mailtrap/ApiTokens/ApiTokenResource.cs | 14 +++++++++++ 3 files changed, 59 insertions(+) create mode 100644 src/Mailtrap.Abstractions/ApiTokens/Requests/ResetApiTokenRequest.cs diff --git a/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs index 328e0b7e..137cd632 100644 --- a/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs +++ b/src/Mailtrap.Abstractions/ApiTokens/IApiTokenResource.cs @@ -46,4 +46,27 @@ public interface IApiTokenResource : IRestResource /// the new token value — store it securely; it is only returned once. /// public Task Reset(CancellationToken cancellationToken = default); + + /// + /// Reset the API token represented by this resource, specifying an expiration for the new token. + /// + /// + /// + /// Request with the optional expiration for the new token. + /// + /// + /// + /// Token to control operation cancellation. + /// + /// + /// + /// New API token details, including the full token value. + /// + /// + /// + /// Expires the requested token and creates a new token with the same permissions. + /// The old token stops working after a short grace period. The response includes + /// the new token value – store it securely; it is only returned once. + /// + public Task Reset(ResetApiTokenRequest request, CancellationToken cancellationToken = default); } diff --git a/src/Mailtrap.Abstractions/ApiTokens/Requests/ResetApiTokenRequest.cs b/src/Mailtrap.Abstractions/ApiTokens/Requests/ResetApiTokenRequest.cs new file mode 100644 index 00000000..aa91958b --- /dev/null +++ b/src/Mailtrap.Abstractions/ApiTokens/Requests/ResetApiTokenRequest.cs @@ -0,0 +1,22 @@ +namespace Mailtrap.ApiTokens.Requests; + + +/// +/// Request to reset an API token. +/// +public sealed record ResetApiTokenRequest +{ + /// + /// Gets or sets the optional expiration for the new token. + /// + /// + /// + /// Optional token expiration as an ISO 8601 date-time.
+ /// Omit for the server default (a 1-year default is being rolled out).
+ /// Use for a token that never expires.
+ /// Past or more-than-5-years-ahead values are rejected with 422. + ///
+ [JsonPropertyName("expires_at")] + [JsonPropertyOrder(1)] + public ApiTokenExpiration? ExpiresAt { get; set; } +} diff --git a/src/Mailtrap/ApiTokens/ApiTokenResource.cs b/src/Mailtrap/ApiTokens/ApiTokenResource.cs index a7b1a511..eeaf812e 100644 --- a/src/Mailtrap/ApiTokens/ApiTokenResource.cs +++ b/src/Mailtrap/ApiTokens/ApiTokenResource.cs @@ -27,4 +27,18 @@ public async Task Reset(CancellationToken cancellationTok return result; } + + public async Task Reset(ResetApiTokenRequest request, CancellationToken cancellationToken = default) + { + Ensure.NotNull(request, nameof(request)); + + var uri = ResourceUri.Append(ResetSegment); + + var result = await RestResourceCommandFactory + .CreatePost(uri, request) + .Execute(cancellationToken) + .ConfigureAwait(false); + + return result; + } } From 251e8de535540586aa0f5f925a897083390b7485 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:32 +0200 Subject: [PATCH 04/12] MT-23076: cover token expiration request serialization with tests --- .../ApiTokens/ApiTokensIntegrationTests.cs | 194 +++++++++++++++++- ..._ShouldThrow_WhenExpirationIsRejected.json | 7 + .../Create_WithExpiration_Success.json | 15 ++ .../Create_WithNeverExpiration_Success.json | 15 ++ .../Reset_WithExpiration_Success.json | 15 ++ .../Reset_WithNeverExpiration_Success.json | 15 ++ .../ApiTokens/ApiTokenExpirationTests.cs | 140 +++++++++++++ .../ApiTokens/ApiTokenResourceTests.cs | 18 ++ tests/Mailtrap.UnitTests/GlobalUsings.cs | 1 + 9 files changed, 413 insertions(+), 7 deletions(-) create mode 100644 tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json create mode 100644 tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithExpiration_Success.json create mode 100644 tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithNeverExpiration_Success.json create mode 100644 tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithExpiration_Success.json create mode 100644 tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithNeverExpiration_Success.json create mode 100644 tests/Mailtrap.UnitTests/ApiTokens/ApiTokenExpirationTests.cs diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs index edd340e3..684a49a2 100644 --- a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs @@ -91,6 +91,79 @@ public async Task Create_Success() result.Should().BeEquivalentTo(expectedResponse); } + [Test] + public async Task Create_WithExpiration_Success() + { + // Arrange + var request = new CreateApiTokenRequest + { + Name = "My API Token", + ExpiresAt = ApiTokenExpiration.At(DateTimeOffset.Parse("2027-06-01T00:00:00Z", CultureInfo.InvariantCulture)) + }; + request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + + const string expectedRequestBody = + """{"name":"My API Token","expires_at":"2027-06-01T00:00:00+00:00","resources":[{"resource_type":"account","resource_id":3229,"access_level":100}]}"""; + + // Act & Assert + await RunCreateSuccessAsync(request, expectedRequestBody); + } + + [Test] + public async Task Create_WithNeverExpiration_Success() + { + // Arrange + var request = new CreateApiTokenRequest + { + Name = "My API Token", + ExpiresAt = ApiTokenExpiration.Never + }; + request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + + const string expectedRequestBody = + """{"name":"My API Token","expires_at":null,"resources":[{"resource_type":"account","resource_id":3229,"access_level":100}]}"""; + + // Act & Assert + await RunCreateSuccessAsync(request, expectedRequestBody); + } + + [Test] + public async Task Create_ShouldThrow_WhenExpirationIsRejected() + { + // Arrange + var request = new CreateApiTokenRequest + { + Name = "My API Token", + ExpiresAt = ApiTokenExpiration.At(DateTimeOffset.Parse("2020-01-01T00:00:00Z", CultureInfo.InvariantCulture)) + }; + request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + + using var responseContent = await Feature.LoadFileToStringContent(); + + using var mockHttp = new MockHttpMessageHandler(); + mockHttp + .Expect(HttpMethod.Post, _resourceUri.AbsoluteUri) + .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}") + .WithHeaders("Accept", MimeTypes.Application.Json) + .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString()) + .Respond(HttpStatusCode.UnprocessableEntity, responseContent); + + using var services = BuildServiceProvider(mockHttp); + var client = services.GetRequiredService(); + + // Act + var act = () => client + .Account(_accountId) + .ApiTokens() + .Create(request); + + // Assert + var assertion = await act.Should().ThrowAsync(); + assertion.Which.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + + mockHttp.VerifyNoOutstandingExpectation(); + } + [Test] public async Task GetDetails_Success() { @@ -163,15 +236,19 @@ public async Task Reset_Success() expectedResponse.Should().NotBeNull(); using var mockHttp = new MockHttpMessageHandler(); - using var clientScope = mockHttp.ConfigureAndCreateClient( - HttpMethod.Post, - requestUri, - responseContent, - HttpStatusCode.OK, - _clientConfig); + mockHttp + .Expect(HttpMethod.Post, requestUri) + .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}") + .WithHeaders("Accept", MimeTypes.Application.Json) + .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString()) + .With(message => message.Content is null) + .Respond(HttpStatusCode.OK, responseContent); + + using var services = BuildServiceProvider(mockHttp); + var client = services.GetRequiredService(); // Act - var result = await clientScope.Client + var result = await client .Account(_accountId) .ApiToken(apiTokenId) .Reset() @@ -182,4 +259,107 @@ public async Task Reset_Success() result.Should().BeEquivalentTo(expectedResponse); } + + [Test] + public async Task Reset_WithExpiration_Success() + { + // Arrange + var request = new ResetApiTokenRequest + { + ExpiresAt = ApiTokenExpiration.At(DateTimeOffset.Parse("2027-06-01T00:00:00Z", CultureInfo.InvariantCulture)) + }; + + const string expectedRequestBody = """{"expires_at":"2027-06-01T00:00:00+00:00"}"""; + + // Act & Assert + await RunResetSuccessAsync(request, expectedRequestBody); + } + + [Test] + public async Task Reset_WithNeverExpiration_Success() + { + // Arrange + var request = new ResetApiTokenRequest + { + ExpiresAt = ApiTokenExpiration.Never + }; + + const string expectedRequestBody = """{"expires_at":null}"""; + + // Act & Assert + await RunResetSuccessAsync(request, expectedRequestBody); + } + + + private async Task RunCreateSuccessAsync(CreateApiTokenRequest request, string expectedRequestBody) + { + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + mockHttp + .Expect(HttpMethod.Post, _resourceUri.AbsoluteUri) + .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}") + .WithHeaders("Accept", MimeTypes.Application.Json) + .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString()) + .WithContent(expectedRequestBody) + .Respond(HttpStatusCode.OK, responseContent); + + using var services = BuildServiceProvider(mockHttp); + var client = services.GetRequiredService(); + + var result = await client + .Account(_accountId) + .ApiTokens() + .Create(request) + .ConfigureAwait(false); + + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + private async Task RunResetSuccessAsync(ResetApiTokenRequest request, string expectedRequestBody) + { + var apiTokenId = TestContext.CurrentContext.Random.NextLong(); + var requestUri = _resourceUri.Append(apiTokenId).Append(ResetSegment).AbsoluteUri; + + using var responseContent = await Feature.LoadFileToStringContent(); + var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); + expectedResponse.Should().NotBeNull(); + + using var mockHttp = new MockHttpMessageHandler(); + mockHttp + .Expect(HttpMethod.Post, requestUri) + .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}") + .WithHeaders("Accept", MimeTypes.Application.Json) + .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString()) + .WithContent(expectedRequestBody) + .Respond(HttpStatusCode.OK, responseContent); + + using var services = BuildServiceProvider(mockHttp); + var client = services.GetRequiredService(); + + var result = await client + .Account(_accountId) + .ApiToken(apiTokenId) + .Reset(request) + .ConfigureAwait(false); + + mockHttp.VerifyNoOutstandingExpectation(); + + result.Should().BeEquivalentTo(expectedResponse); + } + + private ServiceProvider BuildServiceProvider(MockHttpMessageHandler mockHttp) + { + var serviceCollection = new ServiceCollection(); + + serviceCollection + .AddMailtrapClient(_clientConfig) + .ConfigurePrimaryHttpMessageHandler(() => mockHttp); + + return serviceCollection.BuildServiceProvider(); + } } diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json new file mode 100644 index 00000000..5fc1d884 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json @@ -0,0 +1,7 @@ +{ + "errors": { + "expires_at": [ + "must be in the future" + ] + } +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithExpiration_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithExpiration_Success.json new file mode 100644 index 00000000..fddbadb3 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithExpiration_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "My API Token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": "2027-06-01T00:00:00Z", + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithNeverExpiration_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithNeverExpiration_Success.json new file mode 100644 index 00000000..14b63ae6 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_WithNeverExpiration_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "My API Token", + "last_4_digits": "x7k9", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithExpiration_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithExpiration_Success.json new file mode 100644 index 00000000..f7acb5a1 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithExpiration_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "Production token", + "last_4_digits": "z9y8", + "created_by": "user@example.com", + "expires_at": "2027-06-01T00:00:00Z", + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "newtokenvaluereturnedonresetonceonly1234" +} diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithNeverExpiration_Success.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithNeverExpiration_Success.json new file mode 100644 index 00000000..2d0e46b7 --- /dev/null +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Reset_WithNeverExpiration_Success.json @@ -0,0 +1,15 @@ +{ + "id": 12345, + "name": "Production token", + "last_4_digits": "z9y8", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 3229, + "access_level": 100 + } + ], + "token": "newtokenvaluereturnedonresetonceonly1234" +} diff --git a/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenExpirationTests.cs b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenExpirationTests.cs new file mode 100644 index 00000000..509a2cb8 --- /dev/null +++ b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenExpirationTests.cs @@ -0,0 +1,140 @@ +using System.Globalization; + +namespace Mailtrap.UnitTests.ApiTokens; + + +[TestFixture] +internal sealed class ApiTokenExpirationTests +{ + private const string ExpirationDateRaw = "2027-06-01T00:00:00Z"; + private const string ExpirationDateSerialized = "2027-06-01T00:00:00+00:00"; + + private static readonly DateTimeOffset s_expirationDate = + DateTimeOffset.Parse(ExpirationDateRaw, CultureInfo.InvariantCulture); + + + #region CreateApiTokenRequest serialization + + [Test] + public void CreateRequest_ShouldOmitExpiresAtKey_WhenExpirationIsNotSet() + { + var request = new CreateApiTokenRequest { Name = "My API Token" }; + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be("""{"name":"My API Token","resources":[]}"""); + } + + [Test] + public void CreateRequest_ShouldWriteNullExpiresAt_WhenExpirationIsNever() + { + var request = new CreateApiTokenRequest + { + Name = "My API Token", + ExpiresAt = ApiTokenExpiration.Never + }; + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be("""{"name":"My API Token","expires_at":null,"resources":[]}"""); + } + + [Test] + public void CreateRequest_ShouldWriteIsoStringExpiresAt_WhenExpirationIsSet() + { + var request = new CreateApiTokenRequest + { + Name = "My API Token", + ExpiresAt = ApiTokenExpiration.At(s_expirationDate) + }; + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be($$"""{"name":"My API Token","expires_at":"{{ExpirationDateSerialized}}","resources":[]}"""); + } + + #endregion + + + #region ResetApiTokenRequest serialization + + [Test] + public void ResetRequest_ShouldOmitExpiresAtKey_WhenExpirationIsNotSet() + { + var request = new ResetApiTokenRequest(); + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be("{}"); + } + + [Test] + public void ResetRequest_ShouldWriteNullExpiresAt_WhenExpirationIsNever() + { + var request = new ResetApiTokenRequest { ExpiresAt = ApiTokenExpiration.Never }; + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be("""{"expires_at":null}"""); + } + + [Test] + public void ResetRequest_ShouldWriteIsoStringExpiresAt_WhenExpirationIsSet() + { + var request = new ResetApiTokenRequest { ExpiresAt = ApiTokenExpiration.At(s_expirationDate) }; + + var serialized = JsonSerializer.Serialize(request, MailtrapJsonSerializerOptions.NotIndented); + + serialized.Should().Be($$"""{"expires_at":"{{ExpirationDateSerialized}}"}"""); + } + + #endregion + + + #region Deserialization + + [Test] + public void CreateRequest_ShouldDeserializeMissingExpiresAt_ToNull() + { + var deserialized = JsonSerializer.Deserialize( + """{"name":"My API Token","resources":[]}""", + MailtrapJsonSerializerOptions.NotIndented); + + deserialized.Should().NotBeNull(); + deserialized!.ExpiresAt.Should().BeNull(); + } + + [Test] + public void CreateRequest_ShouldDeserializeNullExpiresAt_ToNever() + { + var deserialized = JsonSerializer.Deserialize( + """{"name":"My API Token","expires_at":null,"resources":[]}""", + MailtrapJsonSerializerOptions.NotIndented); + + deserialized.Should().NotBeNull(); + deserialized!.ExpiresAt.Should().Be(ApiTokenExpiration.Never); + } + + [Test] + public void ResetRequest_ShouldDeserializeStringExpiresAt_ToExpirationAtDate() + { + var deserialized = JsonSerializer.Deserialize( + $$"""{"expires_at":"{{ExpirationDateRaw}}"}""", + MailtrapJsonSerializerOptions.NotIndented); + + deserialized.Should().NotBeNull(); + deserialized!.ExpiresAt.Should().Be(ApiTokenExpiration.At(s_expirationDate)); + } + + [Test] + public void ResetRequest_ShouldThrowJsonException_WhenExpiresAtIsNotParseable() + { + var act = () => JsonSerializer.Deserialize( + """{"expires_at":"not-a-date"}""", + MailtrapJsonSerializerOptions.NotIndented); + + act.Should().Throw(); + } + + #endregion +} diff --git a/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs index 4fdff334..6c6820be 100644 --- a/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs +++ b/tests/Mailtrap.UnitTests/ApiTokens/ApiTokenResourceTests.cs @@ -49,6 +49,24 @@ public void ResourceUri_ShouldBeInitializedProperly() #endregion + #region Reset + + [Test] + public async Task Reset_ShouldThrowArgumentNullException_WhenRequestIsNull() + { + // Arrange + var resource = CreateResource(); + + // Act + var act = () => resource.Reset(null!); + + // Assert + await act.Should().ThrowAsync(); + } + + #endregion + + private ApiTokenResource CreateResource() => new(_commandFactoryMock, _resourceUri); } diff --git a/tests/Mailtrap.UnitTests/GlobalUsings.cs b/tests/Mailtrap.UnitTests/GlobalUsings.cs index a4765142..65252806 100644 --- a/tests/Mailtrap.UnitTests/GlobalUsings.cs +++ b/tests/Mailtrap.UnitTests/GlobalUsings.cs @@ -12,6 +12,7 @@ global using Mailtrap.AccountAccesses.Requests; global using Mailtrap.Accounts; global using Mailtrap.ApiTokens; +global using Mailtrap.ApiTokens.Models; global using Mailtrap.ApiTokens.Requests; global using Mailtrap.ApiTokens.Validators; global using Mailtrap.Attachments; From 02902d73575952007b4c9cff627684e513af2ba6 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:32 +0200 Subject: [PATCH 05/12] MT-23076: demonstrate token expiration in api tokens example --- .../Mailtrap.Example.ApiTokens/Program.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/examples/Mailtrap.Example.ApiTokens/Program.cs b/examples/Mailtrap.Example.ApiTokens/Program.cs index 5fee8ac6..20661442 100644 --- a/examples/Mailtrap.Example.ApiTokens/Program.cs +++ b/examples/Mailtrap.Example.ApiTokens/Program.cs @@ -35,10 +35,14 @@ IList apiTokens = await apiTokensResource.GetAll(); logger.LogInformation("Found {Count} API token(s).", apiTokens.Count); - // Create a new API token scoped to a specific inbox with Viewer access + // Create a new API token scoped to a specific inbox with Viewer access. + // ExpiresAt is optional: omit it for the server default expiration, + // use ApiTokenExpiration.Never for a token that never expires, + // or ApiTokenExpiration.At(...) for an explicit expiration date. var createRequest = new CreateApiTokenRequest { - Name = "Demo Viewer Token" + Name = "Demo Viewer Token", + ExpiresAt = ApiTokenExpiration.At(DateTimeOffset.UtcNow.AddMonths(6)) }; createRequest.Resources.Add(new ApiTokenAccessRequest(ResourceType.Inbox, inboxId, AccessLevel.Viewer)); @@ -62,7 +66,8 @@ tokenDetails.Name, tokenDetails.CreatedBy); - // Reset the API token - expires the current token and returns a new one with the same permissions + // Reset the API token - expires the current token and returns a new one with the same permissions. + // The parameterless overload keeps the server default expiration for the new token. ApiTokenResetResponse resetResponse = await apiTokenResource.Reset(); logger.LogInformation( "Reset API Token: Id={Id}, NewLast4={Last4}", @@ -70,6 +75,17 @@ resetResponse.Last4Digits); logger.LogInformation("New token value (store securely, returned only once): {Token}", resetResponse.Token); + // Reset again, this time requesting a new token that never expires + ApiTokenResetResponse neverExpiringToken = await apiTokenResource.Reset(new ResetApiTokenRequest + { + ExpiresAt = ApiTokenExpiration.Never + }); + logger.LogInformation( + "Reset API Token: Id={Id}, NewLast4={Last4}, ExpiresAt={ExpiresAt}", + neverExpiringToken.Id, + neverExpiringToken.Last4Digits, + neverExpiringToken.ExpiresAt); + // Delete the API token // The API token resource becomes invalid after deletion and should not be used anymore await apiTokenResource.Delete(); From f602d4952918db8082b00e0ba69caf8f9272e6d4 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Mon, 3 Aug 2026 14:43:32 +0200 Subject: [PATCH 06/12] MT-23076: add masked_token to account access specifier --- .../AccountAccesses/Models/Specifier.cs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Mailtrap.Abstractions/AccountAccesses/Models/Specifier.cs b/src/Mailtrap.Abstractions/AccountAccesses/Models/Specifier.cs index 2fdc3523..4fd31e45 100644 --- a/src/Mailtrap.Abstractions/AccountAccesses/Models/Specifier.cs +++ b/src/Mailtrap.Abstractions/AccountAccesses/Models/Specifier.cs @@ -79,6 +79,21 @@ public sealed record Specifier [JsonPropertyOrder(5)] public string? Token { get; set; } + /// + /// Gets the token value with all but the last characters masked. + /// + /// + /// + /// Token value with all but the last characters masked. + /// + /// + /// + /// Applicable to 'ApiToken' specifier type only. + /// + [JsonPropertyName("masked_token")] + [JsonPropertyOrder(6)] + public string? MaskedToken { get; set; } + /// /// Gets the token expiration date and time. /// @@ -91,7 +106,7 @@ public sealed record Specifier /// Applicable to 'ApiToken' specifier type only. /// [JsonPropertyName("expires_at")] - [JsonPropertyOrder(6)] + [JsonPropertyOrder(7)] public DateTimeOffset? ExpiresAt { get; set; } /// @@ -107,6 +122,6 @@ public sealed record Specifier /// Applicable to 'User' specifier type only. /// [JsonPropertyName("two_factor_authentication_enabled")] - [JsonPropertyOrder(7)] + [JsonPropertyOrder(8)] public bool? TwoFactorAuthEnabled { get; set; } } From e90beafde9d1c8c33fb482251e9920a8237882d6 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Wed, 26 Aug 2026 15:12:15 +0200 Subject: [PATCH 07/12] MT-23076: write json null instead of throwing for a null expiration --- .../ApiTokenExpirationJsonConverter.cs | 9 +++--- .../ApiTokens/ApiTokenExpirationTests.cs | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs b/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs index 4f71d3c4..87ecff2b 100644 --- a/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs +++ b/src/Mailtrap.Abstractions/ApiTokens/Converters/ApiTokenExpirationJsonConverter.cs @@ -26,15 +26,16 @@ internal sealed class ApiTokenExpirationJsonConverter : JsonConverter Date: Wed, 26 Aug 2026 15:13:43 +0200 Subject: [PATCH 08/12] MT-23076: assert the expiration returned in create and reset responses --- .../ApiTokens/ApiTokensIntegrationTests.cs | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs index 684a49a2..81b149a8 100644 --- a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs @@ -105,8 +105,11 @@ public async Task Create_WithExpiration_Success() const string expectedRequestBody = """{"name":"My API Token","expires_at":"2027-06-01T00:00:00+00:00","resources":[{"resource_type":"account","resource_id":3229,"access_level":100}]}"""; - // Act & Assert - await RunCreateSuccessAsync(request, expectedRequestBody); + // Act + var result = await RunCreateSuccessAsync(request, expectedRequestBody); + + // Assert + result.ExpiresAt.Should().Be(DateTimeOffset.Parse("2027-06-01T00:00:00Z", CultureInfo.InvariantCulture)); } [Test] @@ -123,8 +126,11 @@ public async Task Create_WithNeverExpiration_Success() const string expectedRequestBody = """{"name":"My API Token","expires_at":null,"resources":[{"resource_type":"account","resource_id":3229,"access_level":100}]}"""; - // Act & Assert - await RunCreateSuccessAsync(request, expectedRequestBody); + // Act + var result = await RunCreateSuccessAsync(request, expectedRequestBody); + + // Assert + result.ExpiresAt.Should().BeNull(); } [Test] @@ -271,8 +277,11 @@ public async Task Reset_WithExpiration_Success() const string expectedRequestBody = """{"expires_at":"2027-06-01T00:00:00+00:00"}"""; - // Act & Assert - await RunResetSuccessAsync(request, expectedRequestBody); + // Act + var result = await RunResetSuccessAsync(request, expectedRequestBody); + + // Assert + result.ExpiresAt.Should().Be(DateTimeOffset.Parse("2027-06-01T00:00:00Z", CultureInfo.InvariantCulture)); } [Test] @@ -286,12 +295,15 @@ public async Task Reset_WithNeverExpiration_Success() const string expectedRequestBody = """{"expires_at":null}"""; - // Act & Assert - await RunResetSuccessAsync(request, expectedRequestBody); + // Act + var result = await RunResetSuccessAsync(request, expectedRequestBody); + + // Assert + result.ExpiresAt.Should().BeNull(); } - private async Task RunCreateSuccessAsync(CreateApiTokenRequest request, string expectedRequestBody) + private async Task RunCreateSuccessAsync(CreateApiTokenRequest request, string expectedRequestBody) { using var responseContent = await Feature.LoadFileToStringContent(); var expectedResponse = await responseContent.DeserializeStringContentAsync(_jsonSerializerOptions); @@ -318,9 +330,11 @@ private async Task RunCreateSuccessAsync(CreateApiTokenRequest request, string e mockHttp.VerifyNoOutstandingExpectation(); result.Should().BeEquivalentTo(expectedResponse); + + return result; } - private async Task RunResetSuccessAsync(ResetApiTokenRequest request, string expectedRequestBody) + private async Task RunResetSuccessAsync(ResetApiTokenRequest request, string expectedRequestBody) { var apiTokenId = TestContext.CurrentContext.Random.NextLong(); var requestUri = _resourceUri.Append(apiTokenId).Append(ResetSegment).AbsoluteUri; @@ -350,6 +364,8 @@ private async Task RunResetSuccessAsync(ResetApiTokenRequest request, string exp mockHttp.VerifyNoOutstandingExpectation(); result.Should().BeEquivalentTo(expectedResponse); + + return result; } private ServiceProvider BuildServiceProvider(MockHttpMessageHandler mockHttp) From 1d5b427c3884c8f886671935e7606d89987883a5 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Wed, 26 Aug 2026 15:14:31 +0200 Subject: [PATCH 09/12] MT-23076: cover the masked token specifier field with a test --- .../Mailtrap.UnitTests/AccountAccesses/Models/SpecifierTests.cs | 1 + .../AccountAccesses/TestData/Specifier_Token.json | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/Mailtrap.UnitTests/AccountAccesses/Models/SpecifierTests.cs b/tests/Mailtrap.UnitTests/AccountAccesses/Models/SpecifierTests.cs index 3a54323a..4ef1d8ed 100644 --- a/tests/Mailtrap.UnitTests/AccountAccesses/Models/SpecifierTests.cs +++ b/tests/Mailtrap.UnitTests/AccountAccesses/Models/SpecifierTests.cs @@ -55,6 +55,7 @@ public async Task Deserialize_Token() s.TwoFactorAuthEnabled == null && s.AuthorName == "Author" && s.Token == "token" && + s.MaskedToken == "****oken" && s.ExpiresAt == new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero) ); } diff --git a/tests/Mailtrap.UnitTests/AccountAccesses/TestData/Specifier_Token.json b/tests/Mailtrap.UnitTests/AccountAccesses/TestData/Specifier_Token.json index 5e9e3353..ef8eddec 100644 --- a/tests/Mailtrap.UnitTests/AccountAccesses/TestData/Specifier_Token.json +++ b/tests/Mailtrap.UnitTests/AccountAccesses/TestData/Specifier_Token.json @@ -3,5 +3,6 @@ "name": "Token", "author_name": "Author", "token": "token", + "masked_token": "****oken", "expires_at": "2025-01-01T00:00:00Z" } From 386d051a4484c70cecbc32d5daca6a5ee8b4bfb4 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Wed, 26 Aug 2026 15:15:55 +0200 Subject: [PATCH 10/12] MT-23076: assert the rejected expiration request and error details --- .../ApiTokens/ApiTokensIntegrationTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs index 81b149a8..9cc2c46d 100644 --- a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs @@ -144,6 +144,9 @@ public async Task Create_ShouldThrow_WhenExpirationIsRejected() }; request.Resources.Add(new ApiTokenAccessRequest(ResourceType.Account, 3229, AccessLevel.Admin)); + const string expectedRequestBody = + """{"name":"My API Token","expires_at":"2020-01-01T00:00:00+00:00","resources":[{"resource_type":"account","resource_id":3229,"access_level":100}]}"""; + using var responseContent = await Feature.LoadFileToStringContent(); using var mockHttp = new MockHttpMessageHandler(); @@ -152,6 +155,7 @@ public async Task Create_ShouldThrow_WhenExpirationIsRejected() .WithHeaders("Authorization", $"Bearer {_clientConfig.ApiToken}") .WithHeaders("Accept", MimeTypes.Application.Json) .WithHeaders("User-Agent", HeaderValues.UserAgent.ToString()) + .WithContent(expectedRequestBody) .Respond(HttpStatusCode.UnprocessableEntity, responseContent); using var services = BuildServiceProvider(mockHttp); @@ -166,6 +170,7 @@ public async Task Create_ShouldThrow_WhenExpirationIsRejected() // Assert var assertion = await act.Should().ThrowAsync(); assertion.Which.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); + assertion.Which.Message.Should().Contain("must be in the future"); mockHttp.VerifyNoOutstandingExpectation(); } From 3919803affdcdb0f89348a336f43749e99a4bcab Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Wed, 26 Aug 2026 18:29:50 +0200 Subject: [PATCH 11/12] MT-23076: use the real 422 body for the rejected expiration tests --- .../ApiTokens/ApiTokensIntegrationTests.cs | 2 +- .../Create_ShouldThrow_WhenExpirationIsRejected.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs index 9cc2c46d..c6952b63 100644 --- a/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/ApiTokensIntegrationTests.cs @@ -170,7 +170,7 @@ public async Task Create_ShouldThrow_WhenExpirationIsRejected() // Assert var assertion = await act.Should().ThrowAsync(); assertion.Which.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); - assertion.Which.Message.Should().Contain("must be in the future"); + assertion.Which.Message.Should().Contain("Expiration date must be in the future"); mockHttp.VerifyNoOutstandingExpectation(); } diff --git a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json index 5fc1d884..7fce50cc 100644 --- a/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json +++ b/tests/Mailtrap.IntegrationTests/ApiTokens/Create_ShouldThrow_WhenExpirationIsRejected.json @@ -1,7 +1,7 @@ { "errors": { - "expires_at": [ - "must be in the future" + "base": [ + "Expiration date must be in the future" ] } } From 75d1c5512bd74ebc8230a859563b7594f1ea4ee6 Mon Sep 17 00:00:00 2001 From: Alex Shchyhol Date: Fri, 28 Aug 2026 10:45:37 +0200 Subject: [PATCH 12/12] MT-23076: follow the token chain when resetting in the ApiTokens example --- examples/Mailtrap.Example.ApiTokens/Program.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/examples/Mailtrap.Example.ApiTokens/Program.cs b/examples/Mailtrap.Example.ApiTokens/Program.cs index 20661442..ac8425bb 100644 --- a/examples/Mailtrap.Example.ApiTokens/Program.cs +++ b/examples/Mailtrap.Example.ApiTokens/Program.cs @@ -66,7 +66,9 @@ tokenDetails.Name, tokenDetails.CreatedBy); - // Reset the API token - expires the current token and returns a new one with the same permissions. + // Reset the API token - retires the current token and creates a new one with the same permissions. + // The response carries the id of the NEW token, so the resource must be re-created from it + // before performing further operations. // The parameterless overload keeps the server default expiration for the new token. ApiTokenResetResponse resetResponse = await apiTokenResource.Reset(); logger.LogInformation( @@ -75,7 +77,10 @@ resetResponse.Last4Digits); logger.LogInformation("New token value (store securely, returned only once): {Token}", resetResponse.Token); - // Reset again, this time requesting a new token that never expires + // Re-point the resource at the new token + apiTokenResource = accountResource.ApiToken(resetResponse.Id); + + // Reset the new token, this time requesting a replacement that never expires ApiTokenResetResponse neverExpiringToken = await apiTokenResource.Reset(new ResetApiTokenRequest { ExpiresAt = ApiTokenExpiration.Never @@ -86,7 +91,12 @@ neverExpiringToken.Last4Digits, neverExpiringToken.ExpiresAt); - // Delete the API token + // Re-point the resource at the never-expiring token + apiTokenResource = accountResource.ApiToken(neverExpiringToken.Id); + + // Delete the active (never-expiring) API token. + // The retired tokens (the original and the first reset one) stop working after the server's + // short grace period and need no cleanup. // The API token resource becomes invalid after deletion and should not be used anymore await apiTokenResource.Delete(); logger.LogInformation("API Token Deleted.");