Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
172 changes: 172 additions & 0 deletions Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
using System.Text.Json;
using Tharga.Mcp;
using Tharga.Platform.Mcp;
using Tharga.Team;

namespace Tharga.Platform.Mcp.Tests;

public class PlatformTeamResourceProviderTests
{
private readonly ITeamService _teamService = Substitute.For<ITeamService>();
private readonly IApiKeyAdministrationService _apiKeyService = Substitute.For<IApiKeyAdministrationService>();

private IMcpContext MakeContext(string teamId)
{
var ctx = Substitute.For<IMcpContext>();
ctx.TeamId.Returns(teamId);
ctx.Scope.Returns(McpScope.Team);
return ctx;
}

private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(params T[] items)
{
foreach (var item in items) yield return item;
await Task.CompletedTask;
}

[Fact]
public async Task ListResourcesAsync_NoTeamId_ReturnsEmpty()
{
var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);

var result = await sut.ListResourcesAsync(MakeContext(teamId: null), TestContext.Current.CancellationToken);

Assert.Empty(result);
}

[Fact]
public async Task ListResourcesAsync_WithTeamId_Returns_Three_When_ApiKeyServiceRegistered()
{
var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);

var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken);

Assert.Equal(3, result.Count);
Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.TeamUri);
Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.MembersUri);
Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri);
}

[Fact]
public async Task ListResourcesAsync_WithTeamId_OmitsApiKeys_When_ApiKeyServiceNotRegistered()
{
var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null);

var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken);

Assert.Equal(2, result.Count);
Assert.DoesNotContain(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri);
}

[Fact]
public async Task ReadResourceAsync_NoTeamId_Throws()
{
var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);

await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext(teamId: null), TestContext.Current.CancellationToken));
}

[Fact]
public async Task ReadResourceAsync_UnknownUri_Throws()
{
var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);

await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReadResourceAsync("platform://team/bogus", MakeContext("T-1"), TestContext.Current.CancellationToken));
}

[Fact]
public async Task ReadResourceAsync_TeamUri_ReturnsTeamMetadata()
{
var team = Substitute.For<ITeam>();
team.Key.Returns("T-1");
team.Name.Returns("Acme");
team.Icon.Returns((string)null);
team.ConsentedRoles.Returns(new[] { "viewer" });
_teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team));

var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);
var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken);

using var doc = JsonDocument.Parse(content.Text);
var root = doc.RootElement;
Assert.Equal("T-1", root.GetProperty("key").GetString());
Assert.Equal("Acme", root.GetProperty("name").GetString());
Assert.Equal(1, root.GetProperty("consentedRoles").GetArrayLength());
}

[Fact]
public async Task ReadResourceAsync_TeamUri_NotInCallerTeams_Throws()
{
// The MCP caller's TeamKey claim says T-1 but GetTeamsAsync() returns a different team.
var otherTeam = Substitute.For<ITeam>();
otherTeam.Key.Returns("T-OTHER");
_teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(otherTeam));

var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);

await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken));
}

[Fact]
public async Task ReadResourceAsync_MembersUri_ReturnsMembers()
{
var m1 = Substitute.For<ITeamMember>();
m1.Key.Returns("u-1");
m1.Name.Returns("One");
m1.AccessLevel.Returns(AccessLevel.Owner);
m1.State.Returns(MembershipState.Member);
var m2 = Substitute.For<ITeamMember>();
m2.Key.Returns("u-2");
m2.AccessLevel.Returns(AccessLevel.User);
m2.Invitation.Returns(new Invitation { InviteKey = "inv-abc", EMail = "two@example.com", InviteTime = DateTime.UtcNow });
_teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(m1, m2));

var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);
var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.MembersUri, MakeContext("T-1"), TestContext.Current.CancellationToken);

using var doc = JsonDocument.Parse(content.Text);
var root = doc.RootElement;
Assert.Equal("T-1", root.GetProperty("teamKey").GetString());
var items = root.GetProperty("items");
Assert.Equal(2, items.GetArrayLength());
Assert.False(items[0].GetProperty("invited").GetBoolean());
Assert.True(items[1].GetProperty("invited").GetBoolean());
}

[Fact]
public async Task ReadResourceAsync_ApiKeysUri_ReturnsRedactedKeys()
{
var key = Substitute.For<IApiKey>();
key.Key.Returns("k-1");
key.Name.Returns("Default");
key.ApiKey.Returns("RAW-SECRET-VALUE-SHOULD-BE-REDACTED");
key.AccessLevel.Returns(AccessLevel.Administrator);
key.Roles.Returns(new[] { "Editor" });
key.CreatedBy.Returns("alice");
_apiKeyService.GetKeysAsync("T-1").Returns(ToAsyncEnumerable(key));

var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService);
var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken);

Assert.DoesNotContain("RAW-SECRET-VALUE", content.Text);

using var doc = JsonDocument.Parse(content.Text);
var item = doc.RootElement.GetProperty("items")[0];
Assert.Equal("k-1", item.GetProperty("key").GetString());
Assert.Equal("Default", item.GetProperty("name").GetString());
Assert.Equal("alice", item.GetProperty("createdBy").GetString());
Assert.False(item.TryGetProperty("apiKey", out _));
}

[Fact]
public async Task ReadResourceAsync_ApiKeysUri_NoApiKeyService_Throws()
{
var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null);

await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken));
}
}
126 changes: 126 additions & 0 deletions Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Tharga.Mcp;
using Tharga.Platform.Mcp;
using Tharga.Team;

namespace Tharga.Platform.Mcp.Tests;

public class PlatformUserResourceProviderTests
{
private readonly IUserService _userService = Substitute.For<IUserService>();
private readonly ITeamService _teamService = Substitute.For<ITeamService>();
private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For<IHttpContextAccessor>();

private IMcpContext MakeContext(string userId)
{
var ctx = Substitute.For<IMcpContext>();
ctx.UserId.Returns(userId);
ctx.Scope.Returns(McpScope.User);
return ctx;
}

private static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(params T[] items)
{
foreach (var item in items) yield return item;
await Task.CompletedTask;
}

private PlatformUserResourceProvider CreateSut()
=> new(_userService, _teamService, _httpContextAccessor);

[Fact]
public async Task ListResourcesAsync_AnonymousContext_ReturnsEmpty()
{
var sut = CreateSut();

var result = await sut.ListResourcesAsync(MakeContext(userId: null), TestContext.Current.CancellationToken);

Assert.Empty(result);
}

[Fact]
public async Task ListResourcesAsync_AuthenticatedContext_ReturnsMeResource()
{
var sut = CreateSut();

var result = await sut.ListResourcesAsync(MakeContext(userId: "u-1"), TestContext.Current.CancellationToken);

var descriptor = Assert.Single(result);
Assert.Equal(PlatformUserResourceProvider.MeUri, descriptor.Uri);
Assert.Equal("application/json", descriptor.MimeType);
}

[Fact]
public async Task ReadResourceAsync_UnknownUri_Throws()
{
var sut = CreateSut();

await Assert.ThrowsAsync<InvalidOperationException>(() =>
sut.ReadResourceAsync("platform://nope", MakeContext("u-1"), TestContext.Current.CancellationToken));
}

[Fact]
public async Task ReadResourceAsync_NoCurrentUser_Throws()
{
_userService.GetCurrentUserAsync(Arg.Any<ClaimsPrincipal>()).Returns((IUser)null);
var sut = CreateSut();

await Assert.ThrowsAsync<UnauthorizedAccessException>(() =>
sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-1"), TestContext.Current.CancellationToken));
}

[Fact]
public async Task ReadResourceAsync_ReturnsUserAndMemberships()
{
var user = Substitute.For<IUser>();
user.Key.Returns("u-alice");
user.Identity.Returns("alice@example.com");
user.Name.Returns("Alice");
user.EMail.Returns("alice@example.com");
_userService.GetCurrentUserAsync(Arg.Any<ClaimsPrincipal>()).Returns(user);

var team1 = Substitute.For<ITeam>();
team1.Key.Returns("T-1");
team1.Name.Returns("First");
var team2 = Substitute.For<ITeam>();
team2.Key.Returns("T-2");
team2.Name.Returns("Second");
_teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team1, team2));

var aliceInT1 = Substitute.For<ITeamMember>();
aliceInT1.Key.Returns("u-alice");
aliceInT1.AccessLevel.Returns(AccessLevel.Owner);
aliceInT1.State.Returns(MembershipState.Member);
var bobInT1 = Substitute.For<ITeamMember>();
bobInT1.Key.Returns("u-bob");
_teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(bobInT1, aliceInT1));

var aliceInT2 = Substitute.For<ITeamMember>();
aliceInT2.Key.Returns("u-alice");
aliceInT2.AccessLevel.Returns(AccessLevel.User);
aliceInT2.State.Returns(MembershipState.Member);
_teamService.GetMembersAsync("T-2").Returns(ToAsyncEnumerable(aliceInT2));

var sut = CreateSut();
var content = await sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-alice"), TestContext.Current.CancellationToken);

Assert.Equal(PlatformUserResourceProvider.MeUri, content.Uri);
Assert.Equal("application/json", content.MimeType);

using var doc = JsonDocument.Parse(content.Text);
var root = doc.RootElement;
var userJson = root.GetProperty("user");
Assert.Equal("u-alice", userJson.GetProperty("key").GetString());
Assert.Equal("Alice", userJson.GetProperty("name").GetString());
Assert.Equal("alice@example.com", userJson.GetProperty("email").GetString());

var memberships = root.GetProperty("memberships");
Assert.Equal(2, memberships.GetArrayLength());
var first = memberships[0];
Assert.Equal("T-1", first.GetProperty("teamKey").GetString());
Assert.Equal("First", first.GetProperty("teamName").GetString());
Assert.Equal((int)AccessLevel.Owner, first.GetProperty("accessLevel").GetInt32());
}
}
5 changes: 5 additions & 0 deletions Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public static IThargaMcpBuilder AddPlatform(this IThargaMcpBuilder builder, Acti
scopes.Register(McpScopes.Discover, AccessLevel.Viewer);
});

// Always-on user-scope and team-scope resource providers. They self-gate on the
// principal's UserId / TeamKey claim, so anonymous and system-only callers see nothing.
builder.AddResourceProvider<PlatformUserResourceProvider>();
builder.AddResourceProvider<PlatformTeamResourceProvider>();

// Opt-in system-scope resource providers (diagnostic data for Developers).
if (options.ExposeSystemResources)
{
Expand Down
Loading
Loading