Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Src/Notion.Client/Api/ApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ public static class AuthenticationUrls
{
public static string CreateToken() => "/v1/oauth/token";
public static string RevokeToken() => "/v1/oauth/revoke";
public static string IntrospectToken() => "/v1/oauth/introspect";
}
}
}
25 changes: 25 additions & 0 deletions Src/Notion.Client/Api/Authentication/BasicAuthParamValidator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System;

namespace Notion.Client
{
public static class BasicAuthParamValidator
{
public static void Validate(IBasicAuthenticationParameters basicAuthParams)
{
if (basicAuthParams == null)
{
throw new ArgumentNullException(nameof(basicAuthParams), "Basic authentication parameters must be provided.");
}

if (string.IsNullOrWhiteSpace(basicAuthParams.ClientId))
{
throw new ArgumentException("ClientId must be provided.", nameof(basicAuthParams.ClientId));
}

if (string.IsNullOrWhiteSpace(basicAuthParams.ClientSecret))
{
throw new ArgumentException("ClientSecret must be provided.", nameof(basicAuthParams.ClientSecret));
}
}
}
}
11 changes: 11 additions & 0 deletions Src/Notion.Client/Api/Authentication/IAuthenticationClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,16 @@ Task RevokeTokenAsync(
RevokeTokenRequest revokeTokenRequest,
CancellationToken cancellationToken = default
);

/// <summary>
/// Get a token's active status, scope, and issued time.
/// </summary>
/// <param name="introspectTokenRequest"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<IntrospectTokenResponse> IntrospectTokenAsync(
IntrospectTokenRequest introspectTokenRequest,
CancellationToken cancellationToken = default
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Notion.Client
{
public sealed partial class AuthenticationClient
{
public async Task<IntrospectTokenResponse> IntrospectTokenAsync(
IntrospectTokenRequest introspectTokenRequest,
CancellationToken cancellationToken = default)
{
if (introspectTokenRequest is null)
{
throw new ArgumentNullException(nameof(introspectTokenRequest));
}

IIntrospectTokenBodyParameters body = introspectTokenRequest;
IBasicAuthenticationParameters basicAuth = introspectTokenRequest;

if (string.IsNullOrWhiteSpace(body.Token))
{
throw new ArgumentException("Token must be provided.", nameof(body.Token));
}

BasicAuthParamValidator.Validate(basicAuth);

return await _client.PostAsync<IntrospectTokenResponse>(
ApiEndpoints.AuthenticationUrls.IntrospectToken(),
body,
basicAuthenticationParameters: basicAuth,
cancellationToken: cancellationToken
);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public interface IIntrospectTokenBodyParameters
{
/// <summary>
/// The access token
/// </summary>
[JsonProperty(PropertyName = "token")]
public string Token { get; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace Notion.Client
{
public class IntrospectTokenRequest : IIntrospectTokenBodyParameters, IBasicAuthenticationParameters
{
public string Token { get; set; }

public string ClientId { get; set; }

public string ClientSecret { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Newtonsoft.Json;

namespace Notion.Client
{
public class IntrospectTokenResponse
{
[JsonProperty("active")]
public bool IsActive { get; set; }

[JsonProperty("scope")]
public string Scope { get; set; }

[JsonProperty("iat")]
public long Iat { get; set; }
}
}
41 changes: 40 additions & 1 deletion Test/Notion.IntegrationTests/AuthenticationClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,46 @@ public async Task Create_and_revoke_token()
// Assert
Assert.NotNull(response);
Assert.NotNull(response.AccessToken);


// revoke token
await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest
{
Token = response.AccessToken,
ClientId = _clientId,
ClientSecret = _clientSecret
});
}

[Fact]
public async Task Introspect_token()
{
// Arrange
var createRequest = new CreateTokenRequest
{
Code = "036822b2-62c1-42f4-95ea-0153e69cc20e",
ClientId = _clientId,
ClientSecret = _clientSecret,
RedirectUri = "https://localhost:5001",
};

// Act
var response = await Client.AuthenticationClient.CreateTokenAsync(createRequest);

// Assert
Assert.NotNull(response);
Assert.NotNull(response.AccessToken);

// introspect token
var introspectResponse = await Client.AuthenticationClient.IntrospectTokenAsync(new IntrospectTokenRequest
{
Token = response.AccessToken,
ClientId = _clientId,
ClientSecret = _clientSecret
});

Assert.NotNull(introspectResponse);
Assert.True(introspectResponse.IsActive);

// revoke token
await Client.AuthenticationClient.RevokeTokenAsync(new RevokeTokenRequest
{
Expand Down
133 changes: 133 additions & 0 deletions Test/Notion.UnitTests/AuthenticationClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using Moq.AutoMock;
using Newtonsoft.Json;
using Notion.Client;
using Xunit;

namespace Notion.UnitTests;

public class AuthenticationClientTests
{
private readonly AutoMocker _mocker = new();
private readonly Mock<IRestClient> _restClientMock;
private readonly AuthenticationClient _authenticationClient;

public AuthenticationClientTests()
{
_restClientMock = _mocker.GetMock<IRestClient>();
_authenticationClient = _mocker.CreateInstance<AuthenticationClient>();
}

[Fact]
public async Task IntrospectTokenAsync_ThrowsArgumentNullException_WhenRequestIsNull()
{
// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentNullException>(() => _authenticationClient.IntrospectTokenAsync(null));
Assert.Equal("introspectTokenRequest", exception.ParamName);
Assert.Equal("Value cannot be null. (Parameter 'introspectTokenRequest')", exception.Message);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty(string token)

Check warning on line 38 in Test/Notion.UnitTests/AuthenticationClientTests.cs

View workflow job for this annotation

GitHub Actions / build

Theory method 'IntrospectTokenAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty' on test class 'AuthenticationClientTests' does not use parameter 'token'.

Check warning on line 38 in Test/Notion.UnitTests/AuthenticationClientTests.cs

View workflow job for this annotation

GitHub Actions / build

Theory method 'IntrospectTokenAsync_ThrowsArgumentException_WhenTokenIsNullOrEmpty' on test class 'AuthenticationClientTests' does not use parameter 'token'.
{
// Arrange
var request = new IntrospectTokenRequest
{
Token = null,
ClientId = "validClientId",
ClientSecret = "validClientSecret"
};

// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _authenticationClient.IntrospectTokenAsync(request));
Assert.Equal("Token", exception.ParamName);
Assert.Equal("Token must be provided. (Parameter 'Token')", exception.Message);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenClientIdIsNullOrEmpty(string clientId)
{
// Arrange
var request = new IntrospectTokenRequest
{
Token = "validToken",
ClientId = clientId,
ClientSecret = "validClientSecret"
};
Comment on lines +61 to +66
Copy link

Copilot AI Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar issue - the test correctly uses the clientId parameter from InlineData, but the previous test for Token validation doesn't follow the same pattern.

Copilot uses AI. Check for mistakes.

// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _authenticationClient.IntrospectTokenAsync(request));
Assert.Equal("ClientId", exception.ParamName);
Assert.Equal("ClientId must be provided. (Parameter 'ClientId')", exception.Message);
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task IntrospectTokenAsync_ThrowsArgumentException_WhenClientSecretIsNullOrEmpty(string clientSecret)
{
// Arrange
var request = new IntrospectTokenRequest
{
Token = "validToken",
ClientId = "validClientId",
ClientSecret = clientSecret
};

// Act & Assert
var exception = await Assert.ThrowsAsync<ArgumentException>(() => _authenticationClient.IntrospectTokenAsync(request));
Assert.Equal("ClientSecret", exception.ParamName);
Assert.Equal("ClientSecret must be provided. (Parameter 'ClientSecret')", exception.Message);
}

[Fact]
public async Task IntrospectTokenAsync_CallsPostAsync_WithCorrectParameters()
{
// Arrange
var introspectTokenRequest = new IntrospectTokenRequest
{
Token = "validToken",
ClientId = "validClientId",
ClientSecret = "validClientSecret"
};

var expectedResponse = new IntrospectTokenResponse
{
IsActive = true,
Scope = "read write",
Iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
};

_restClientMock
.Setup(client => client.PostAsync<IntrospectTokenResponse>(
It.Is<string>(url => url == ApiEndpoints.AuthenticationUrls.IntrospectToken()),
It.IsAny<IIntrospectTokenBodyParameters>(),
It.IsAny<IEnumerable<KeyValuePair<string, string>>>(),
It.IsAny<IDictionary<string, string>>(),
It.IsAny<JsonSerializerSettings>(),
It.IsAny<IBasicAuthenticationParameters>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(expectedResponse);

// Act
var response = await _authenticationClient.IntrospectTokenAsync(introspectTokenRequest);

// Assert
Assert.NotNull(response);
Assert.Equal(expectedResponse.IsActive, response.IsActive);
Assert.Equal(expectedResponse.Scope, response.Scope);
Assert.Equal(expectedResponse.Iat, response.Iat);
_restClientMock.VerifyAll();
}
}
1 change: 1 addition & 0 deletions Test/Notion.UnitTests/Notion.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.VisualStudio.VsixColorCompiler" Version="17.11.35325.10" />
<PackageReference Include="Moq.AutoMock" Version="3.5.0" />
<PackageReference Include="WireMock.Net" Version="1.4.19" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
Expand Down
Loading