Skip to content

Commit 87f4841

Browse files
committed
test: add coverage for uncovered CoreIdent.Core models, stores, and interfaces
- OpenIdConfigurationDocument: all 3 constructor overloads - InMemoryUserGrantStore: MergeScopesAsync (create new + merge existing + validation) - IUserGrantStore: default interface MergeScopesAsync via stub - PasswordlessToken: property defaults and round-trip
1 parent fdecf48 commit 87f4841

4 files changed

Lines changed: 289 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
using CoreIdent.Core.Models;
2+
using Shouldly;
3+
using Xunit;
4+
5+
namespace CoreIdent.Core.Tests.Models;
6+
7+
public class OpenIdConfigurationDocumentTests
8+
{
9+
private static readonly IReadOnlyList<string> GrantTypes = ["authorization_code", "client_credentials"];
10+
private static readonly IReadOnlyList<string> Scopes = ["openid", "profile"];
11+
private static readonly IReadOnlyList<string> Algorithms = ["RS256"];
12+
13+
[Fact]
14+
public void Ctor_8param_sets_required_fields_and_nulls_optional()
15+
{
16+
var doc = new OpenIdConfigurationDocument(
17+
Issuer: "https://issuer.example.com",
18+
JwksUri: "https://issuer.example.com/.well-known/jwks.json",
19+
TokenEndpoint: "https://issuer.example.com/token",
20+
RevocationEndpoint: "https://issuer.example.com/revoke",
21+
IntrospectionEndpoint: "https://issuer.example.com/introspect",
22+
GrantTypesSupported: GrantTypes,
23+
ScopesSupported: Scopes,
24+
IdTokenSigningAlgValuesSupported: Algorithms);
25+
26+
doc.Issuer.ShouldBe("https://issuer.example.com", "Issuer should be set.");
27+
doc.JwksUri.ShouldBe("https://issuer.example.com/.well-known/jwks.json", "JwksUri should be set.");
28+
doc.TokenEndpoint.ShouldBe("https://issuer.example.com/token", "TokenEndpoint should be set.");
29+
doc.RevocationEndpoint.ShouldBe("https://issuer.example.com/revoke", "RevocationEndpoint should be set.");
30+
doc.IntrospectionEndpoint.ShouldBe("https://issuer.example.com/introspect", "IntrospectionEndpoint should be set.");
31+
doc.GrantTypesSupported.ShouldBe(GrantTypes, "GrantTypesSupported should be set.");
32+
doc.ScopesSupported.ShouldBe(Scopes, "ScopesSupported should be set.");
33+
doc.IdTokenSigningAlgValuesSupported.ShouldBe(Algorithms, "IdTokenSigningAlgValuesSupported should be set.");
34+
doc.ResponseTypesSupported.ShouldBeNull("ResponseTypesSupported should default to null.");
35+
doc.TokenEndpointAuthMethodsSupported.ShouldBeNull("TokenEndpointAuthMethodsSupported should default to null.");
36+
doc.AuthorizationEndpoint.ShouldBeNull("AuthorizationEndpoint should default to null.");
37+
doc.UserInfoEndpoint.ShouldBeNull("UserInfoEndpoint should default to null.");
38+
}
39+
40+
[Fact]
41+
public void Ctor_10param_sets_response_types_and_auth_methods()
42+
{
43+
var responseTypes = new List<string> { "code" };
44+
var authMethods = new List<string> { "client_secret_basic", "client_secret_post" };
45+
46+
var doc = new OpenIdConfigurationDocument(
47+
Issuer: "https://issuer.example.com",
48+
JwksUri: "https://issuer.example.com/.well-known/jwks.json",
49+
TokenEndpoint: "https://issuer.example.com/token",
50+
RevocationEndpoint: "https://issuer.example.com/revoke",
51+
IntrospectionEndpoint: "https://issuer.example.com/introspect",
52+
GrantTypesSupported: GrantTypes,
53+
ScopesSupported: Scopes,
54+
IdTokenSigningAlgValuesSupported: Algorithms,
55+
ResponseTypesSupported: responseTypes,
56+
TokenEndpointAuthMethodsSupported: authMethods);
57+
58+
doc.ResponseTypesSupported.ShouldBe(responseTypes, "ResponseTypesSupported should be set.");
59+
doc.TokenEndpointAuthMethodsSupported.ShouldBe(authMethods, "TokenEndpointAuthMethodsSupported should be set.");
60+
doc.AuthorizationEndpoint.ShouldBeNull("AuthorizationEndpoint should default to null.");
61+
doc.UserInfoEndpoint.ShouldBeNull("UserInfoEndpoint should default to null.");
62+
}
63+
64+
[Fact]
65+
public void Ctor_12param_sets_all_fields()
66+
{
67+
var doc = new OpenIdConfigurationDocument(
68+
Issuer: "https://issuer.example.com",
69+
JwksUri: "https://issuer.example.com/.well-known/jwks.json",
70+
TokenEndpoint: "https://issuer.example.com/token",
71+
RevocationEndpoint: "https://issuer.example.com/revoke",
72+
IntrospectionEndpoint: "https://issuer.example.com/introspect",
73+
GrantTypesSupported: GrantTypes,
74+
ScopesSupported: Scopes,
75+
IdTokenSigningAlgValuesSupported: Algorithms,
76+
ResponseTypesSupported: ["code"],
77+
TokenEndpointAuthMethodsSupported: ["client_secret_basic"],
78+
AuthorizationEndpoint: "https://issuer.example.com/authorize",
79+
UserInfoEndpoint: "https://issuer.example.com/userinfo");
80+
81+
doc.AuthorizationEndpoint.ShouldBe("https://issuer.example.com/authorize", "AuthorizationEndpoint should be set.");
82+
doc.UserInfoEndpoint.ShouldBe("https://issuer.example.com/userinfo", "UserInfoEndpoint should be set.");
83+
}
84+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
using CoreIdent.Core.Models;
2+
using Shouldly;
3+
using Xunit;
4+
5+
namespace CoreIdent.Core.Tests.Models;
6+
7+
public class PasswordlessTokenTests
8+
{
9+
[Fact]
10+
public void Properties_default_to_expected_values()
11+
{
12+
var token = new PasswordlessToken();
13+
14+
token.Id.ShouldBe(string.Empty, "Id should default to empty string.");
15+
token.Recipient.ShouldBe(string.Empty, "Recipient should default to empty string.");
16+
token.TokenType.ShouldBe(string.Empty, "TokenType should default to empty string.");
17+
token.TokenHash.ShouldBe(string.Empty, "TokenHash should default to empty string.");
18+
token.CreatedAt.ShouldBe(default(DateTime), "CreatedAt should default to default DateTime.");
19+
token.ExpiresAt.ShouldBe(default(DateTime), "ExpiresAt should default to default DateTime.");
20+
token.Consumed.ShouldBeFalse("Consumed should default to false.");
21+
token.UserId.ShouldBeNull("UserId should default to null.");
22+
}
23+
24+
[Fact]
25+
public void Properties_round_trip_correctly()
26+
{
27+
var now = DateTime.UtcNow;
28+
var token = new PasswordlessToken
29+
{
30+
Id = "tok-1",
31+
Recipient = "user@example.com",
32+
TokenType = "email",
33+
TokenHash = "abc123",
34+
CreatedAt = now,
35+
ExpiresAt = now.AddMinutes(15),
36+
Consumed = true,
37+
UserId = "user-1"
38+
};
39+
40+
token.Id.ShouldBe("tok-1", "Id should round-trip.");
41+
token.Recipient.ShouldBe("user@example.com", "Recipient should round-trip.");
42+
token.TokenType.ShouldBe("email", "TokenType should round-trip.");
43+
token.TokenHash.ShouldBe("abc123", "TokenHash should round-trip.");
44+
token.CreatedAt.ShouldBe(now, "CreatedAt should round-trip.");
45+
token.ExpiresAt.ShouldBe(now.AddMinutes(15), "ExpiresAt should round-trip.");
46+
token.Consumed.ShouldBeTrue("Consumed should round-trip.");
47+
token.UserId.ShouldBe("user-1", "UserId should round-trip.");
48+
}
49+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
using CoreIdent.Core.Models;
2+
using CoreIdent.Core.Stores;
3+
using Shouldly;
4+
using Xunit;
5+
6+
namespace CoreIdent.Core.Tests.Stores;
7+
8+
/// <summary>
9+
/// Tests the default interface method <see cref="IUserGrantStore.MergeScopesAsync"/>
10+
/// using a minimal stub that does NOT override the default.
11+
/// </summary>
12+
public class IUserGrantStoreDefaultTests
13+
{
14+
/// <summary>
15+
/// Minimal stub that only implements required members, leaving the default MergeScopesAsync.
16+
/// </summary>
17+
private sealed class StubUserGrantStore : IUserGrantStore
18+
{
19+
private readonly Dictionary<string, CoreIdentUserGrant> _grants = new(StringComparer.Ordinal);
20+
21+
public Task<CoreIdentUserGrant?> FindAsync(string subjectId, string clientId, CancellationToken ct = default)
22+
{
23+
_grants.TryGetValue($"{subjectId}::{clientId}", out var grant);
24+
return Task.FromResult(grant);
25+
}
26+
27+
public Task SaveAsync(CoreIdentUserGrant grant, CancellationToken ct = default)
28+
{
29+
_grants[$"{grant.SubjectId}::{grant.ClientId}"] = grant;
30+
return Task.CompletedTask;
31+
}
32+
33+
public Task RevokeAsync(string subjectId, string clientId, CancellationToken ct = default)
34+
{
35+
_grants.Remove($"{subjectId}::{clientId}");
36+
return Task.CompletedTask;
37+
}
38+
39+
public Task<bool> HasUserGrantedConsentAsync(string subjectId, string clientId, IEnumerable<string> scopes, CancellationToken ct = default)
40+
{
41+
if (!_grants.TryGetValue($"{subjectId}::{clientId}", out var grant))
42+
return Task.FromResult(false);
43+
var granted = grant.Scopes.ToHashSet(StringComparer.Ordinal);
44+
return Task.FromResult(scopes.All(s => granted.Contains(s)));
45+
}
46+
}
47+
48+
[Fact]
49+
public async Task Default_MergeScopesAsync_creates_grant_when_none_exists()
50+
{
51+
IUserGrantStore store = new StubUserGrantStore();
52+
53+
await store.MergeScopesAsync("sub-d1", "client-d1", ["openid", "profile"]);
54+
55+
var grant = await store.FindAsync("sub-d1", "client-d1");
56+
grant.ShouldNotBeNull("Default MergeScopesAsync should create a new grant.");
57+
grant!.Scopes.ShouldBe(new[] { "openid", "profile" }, "New grant should contain the provided scopes.");
58+
}
59+
60+
[Fact]
61+
public async Task Default_MergeScopesAsync_merges_into_existing_grant()
62+
{
63+
IUserGrantStore store = new StubUserGrantStore();
64+
65+
await store.SaveAsync(new CoreIdentUserGrant
66+
{
67+
SubjectId = "sub-d2",
68+
ClientId = "client-d2",
69+
Scopes = ["openid"],
70+
CreatedAt = DateTime.UtcNow
71+
});
72+
73+
await store.MergeScopesAsync("sub-d2", "client-d2", ["profile", "email"]);
74+
75+
var grant = await store.FindAsync("sub-d2", "client-d2");
76+
grant.ShouldNotBeNull("Grant should still exist after merge.");
77+
grant!.Scopes.Count.ShouldBe(3, "Merged grant should have union of scopes.");
78+
grant.Scopes.ShouldContain("openid", "Existing scope should be preserved.");
79+
grant.Scopes.ShouldContain("profile", "New scope should be added.");
80+
grant.Scopes.ShouldContain("email", "New scope should be added.");
81+
}
82+
83+
[Fact]
84+
public async Task Default_MergeScopesAsync_throws_for_invalid_arguments()
85+
{
86+
IUserGrantStore store = new StubUserGrantStore();
87+
88+
await Should.ThrowAsync<ArgumentException>(
89+
() => store.MergeScopesAsync("", "client", ["openid"]),
90+
"Default MergeScopesAsync should throw for empty subjectId.");
91+
92+
await Should.ThrowAsync<ArgumentException>(
93+
() => store.MergeScopesAsync("sub", "", ["openid"]),
94+
"Default MergeScopesAsync should throw for empty clientId.");
95+
96+
await Should.ThrowAsync<ArgumentNullException>(
97+
() => store.MergeScopesAsync("sub", "client", null!),
98+
"Default MergeScopesAsync should throw for null scopes.");
99+
}
100+
}

tests/CoreIdent.Core.Tests/Stores/InMemoryUserGrantStoreTests.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,60 @@ public async Task SaveAsync_throws_for_invalid_arguments()
126126
var missingClient = new CoreIdentUserGrant { SubjectId = "s", ClientId = "", Scopes = [], CreatedAt = DateTime.UtcNow };
127127
await Should.ThrowAsync<ArgumentException>(() => store.SaveAsync(missingClient), "SaveAsync should throw when ClientId is missing.");
128128
}
129+
130+
[Fact]
131+
public async Task MergeScopesAsync_creates_new_grant_when_none_exists()
132+
{
133+
var time = new MutableTimeProvider(new DateTimeOffset(2025, 12, 12, 0, 0, 0, TimeSpan.Zero));
134+
var store = new InMemoryUserGrantStore(time);
135+
136+
await store.MergeScopesAsync("sub-m1", "client-m1", ["openid", "profile"]);
137+
138+
var grant = await store.FindAsync("sub-m1", "client-m1");
139+
grant.ShouldNotBeNull("MergeScopesAsync should create a new grant when none exists.");
140+
grant!.Scopes.ShouldBe(new[] { "openid", "profile" }, "New grant should contain the merged scopes.");
141+
grant.SubjectId.ShouldBe("sub-m1", "Grant SubjectId should match.");
142+
grant.ClientId.ShouldBe("client-m1", "Grant ClientId should match.");
143+
}
144+
145+
[Fact]
146+
public async Task MergeScopesAsync_merges_into_existing_grant()
147+
{
148+
var store = new InMemoryUserGrantStore();
149+
150+
await store.SaveAsync(new CoreIdentUserGrant
151+
{
152+
SubjectId = "sub-m2",
153+
ClientId = "client-m2",
154+
Scopes = ["openid", "profile"],
155+
CreatedAt = DateTime.UtcNow
156+
});
157+
158+
await store.MergeScopesAsync("sub-m2", "client-m2", ["email", "openid"]);
159+
160+
var grant = await store.FindAsync("sub-m2", "client-m2");
161+
grant.ShouldNotBeNull("Grant should still exist after merge.");
162+
grant!.Scopes.Count.ShouldBe(3, "Merged grant should have union of scopes.");
163+
grant.Scopes.ShouldContain("openid", "Existing scope should be preserved.");
164+
grant.Scopes.ShouldContain("profile", "Existing scope should be preserved.");
165+
grant.Scopes.ShouldContain("email", "New scope should be added.");
166+
}
167+
168+
[Fact]
169+
public async Task MergeScopesAsync_throws_for_invalid_arguments()
170+
{
171+
var store = new InMemoryUserGrantStore();
172+
173+
await Should.ThrowAsync<ArgumentException>(
174+
() => store.MergeScopesAsync("", "client", ["openid"]),
175+
"MergeScopesAsync should throw for empty subjectId.");
176+
177+
await Should.ThrowAsync<ArgumentException>(
178+
() => store.MergeScopesAsync("sub", "", ["openid"]),
179+
"MergeScopesAsync should throw for empty clientId.");
180+
181+
await Should.ThrowAsync<ArgumentNullException>(
182+
() => store.MergeScopesAsync("sub", "client", null!),
183+
"MergeScopesAsync should throw for null scopes.");
184+
}
129185
}

0 commit comments

Comments
 (0)