11using System . Net ;
22using System . Net . Http . Headers ;
33using System . Text ;
4+ using EssentialCSharp . Web . Areas . Identity . Data ;
5+ using EssentialCSharp . Web . Data ;
46using EssentialCSharp . Web . Services ;
57using Microsoft . AspNetCore . Mvc . Testing ;
68using Microsoft . Extensions . DependencyInjection ;
7- using System . Threading . Tasks ;
89
910namespace EssentialCSharp . Web . Tests ;
1011
11- public class McpTests
12+ [ NotInParallel ( "McpTests" ) ]
13+ [ ClassDataSource < WebApplicationFactory > ( Shared = SharedType . PerClass ) ]
14+ public class McpTests ( WebApplicationFactory factory )
1215{
13- [ Fact ]
16+ [ Test ]
1417 public async Task McpTokenEndpoint_WithoutAuth_Returns401 ( )
1518 {
16- using WebApplicationFactory factory = new ( ) ;
1719 HttpClient client = factory . CreateClient ( new WebApplicationFactoryClientOptions
1820 {
1921 AllowAutoRedirect = false
2022 } ) ;
2123
2224 using HttpResponseMessage response = await client . PostAsync ( "/api/McpToken" , null ) ;
2325
24- // [ApiController] returns 401 directly; it does not redirect to login like Razor Pages
25- Assert . Equal ( HttpStatusCode . Unauthorized , response . StatusCode ) ;
26+ await Assert . That ( response . StatusCode ) . IsEqualTo ( HttpStatusCode . Unauthorized ) ;
2627 }
2728
28- [ Fact ]
29+ [ Test ]
2930 public async Task McpEndpoint_WithoutToken_Returns401 ( )
3031 {
31- using WebApplicationFactory factory = new ( ) ;
3232 HttpClient client = factory . CreateClient ( ) ;
3333
3434 var request = CreateMcpInitializeRequest ( "/mcp" ) ;
3535 using HttpResponseMessage response = await client . SendAsync ( request ) ;
3636
37- Assert . Equal ( HttpStatusCode . Unauthorized , response . StatusCode ) ;
37+ await Assert . That ( response . StatusCode ) . IsEqualTo ( HttpStatusCode . Unauthorized ) ;
3838 }
3939
40- [ Fact ]
40+ [ Test ]
4141 public async Task McpEndpoint_WithValidToken_Returns200AndListsTools ( )
4242 {
43- using WebApplicationFactory factory = new ( ) ;
44-
45- McpTokenService ? tokenService = factory . Services . GetService < McpTokenService > ( ) ;
46- Assert . NotNull ( tokenService ) ;
47-
48- var ( token , _) = tokenService . GenerateToken ( "test-user-id" , "testuser" , "test@example.com" ) ;
43+ // Seed a minimal user row to satisfy the FK on McpApiToken.UserId, then
44+ // create an opaque token via McpApiTokenService (replaces old JWT path).
45+ string testUserId = Guid . NewGuid ( ) . ToString ( ) ;
46+ string rawToken ;
47+ using ( var scope = factory . Services . CreateScope ( ) )
48+ {
49+ var db = scope . ServiceProvider . GetRequiredService < EssentialCSharpWebContext > ( ) ;
50+ db . Users . Add ( new EssentialCSharpWebUser
51+ {
52+ Id = testUserId ,
53+ UserName = "mcp-testuser" ,
54+ NormalizedUserName = "MCP-TESTUSER" ,
55+ Email = "mcp-test@example.com" ,
56+ NormalizedEmail = "MCP-TEST@EXAMPLE.COM" ,
57+ SecurityStamp = Guid . NewGuid ( ) . ToString ( ) ,
58+ } ) ;
59+ await db . SaveChangesAsync ( ) ;
60+
61+ var tokenService = scope . ServiceProvider . GetRequiredService < McpApiTokenService > ( ) ;
62+ ( rawToken , _ ) = await tokenService . CreateTokenAsync ( testUserId , "integration-test" ) ;
63+ }
4964
5065 HttpClient client = factory . CreateClient ( ) ;
5166
5267 // Step 1: Initialize the MCP session
5368 var initRequest = CreateMcpInitializeRequest ( "/mcp" ) ;
54- initRequest . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , token ) ;
69+ initRequest . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , rawToken ) ;
5570
5671 using HttpResponseMessage initResponse = await client . SendAsync ( initRequest ) ;
57- Assert . Equal ( HttpStatusCode . OK , initResponse . StatusCode ) ;
72+ await Assert . That ( initResponse . StatusCode ) . IsEqualTo ( HttpStatusCode . OK ) ;
5873
59- string sessionId = initResponse . Headers . GetValues ( "Mcp-Session-Id" ) . First ( ) ;
74+ // Session ID is optional in stateless transport mode
75+ string ? sessionId = null ;
76+ if ( initResponse . Headers . TryGetValues ( "Mcp-Session-Id" , out IEnumerable < string > ? sessionIdValues ) )
77+ sessionId = sessionIdValues . First ( ) ;
6078
6179 // Step 2: List tools
6280 var listToolsRequest = new HttpRequestMessage ( HttpMethod . Post , "/mcp" )
@@ -65,31 +83,106 @@ public async Task McpEndpoint_WithValidToken_Returns200AndListsTools()
6583 """{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""" ,
6684 Encoding . UTF8 , "application/json" )
6785 } ;
68- listToolsRequest . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , token ) ;
86+ listToolsRequest . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , rawToken ) ;
6987 listToolsRequest . Headers . Accept . ParseAdd ( "application/json" ) ;
7088 listToolsRequest . Headers . Accept . ParseAdd ( "text/event-stream" ) ;
71- listToolsRequest . Headers . Add ( "Mcp-Session-Id" , sessionId ) ;
89+ if ( sessionId is not null )
90+ listToolsRequest . Headers . Add ( "Mcp-Session-Id" , sessionId ) ;
7291
7392 using HttpResponseMessage toolsResponse = await client . SendAsync (
7493 listToolsRequest , HttpCompletionOption . ResponseHeadersRead ) ;
75- Assert . Equal ( HttpStatusCode . OK , toolsResponse . StatusCode ) ;
94+ await Assert . That ( toolsResponse . StatusCode ) . IsEqualTo ( HttpStatusCode . OK ) ;
7695
77- // SSE streams arrive line-by-line; read until we find the data line or timeout
96+ // Streamable HTTP response: read until we find the tool names or timeout
7897 using Stream stream = await toolsResponse . Content . ReadAsStreamAsync ( ) ;
7998 using StreamReader reader = new ( stream ) ;
8099 using CancellationTokenSource cts = new ( TimeSpan . FromSeconds ( 10 ) ) ;
81- string body = "" ;
100+ var body = new StringBuilder ( ) ;
82101 string ? line ;
83102 while ( ( line = await reader . ReadLineAsync ( cts . Token ) ) is not null )
84103 {
85- body += line + "\n " ;
86- if ( body . Contains ( "search_book_content" ) && body . Contains ( "get_chapter_list" ) )
104+ body . AppendLine ( line ) ;
105+ if ( body . ToString ( ) . Contains ( "search_book_content" ) &&
106+ body . ToString ( ) . Contains ( "get_chapter_list" ) )
87107 break ;
88108 }
89109
90- // The MCP C# SDK converts PascalCase method names to snake_case for the wire protocol
91- Assert . Contains ( "search_book_content" , body ) ;
92- Assert . Contains ( "get_chapter_list" , body ) ;
110+ string bodyText = body . ToString ( ) ;
111+ await Assert . That ( bodyText ) . Contains ( "search_book_content" ) ;
112+ await Assert . That ( bodyText ) . Contains ( "get_chapter_list" ) ;
113+ }
114+
115+ [ Test ]
116+ public async Task McpEndpoint_WithInvalidToken_Returns401 ( )
117+ {
118+ HttpClient client = factory . CreateClient ( ) ;
119+ var request = CreateMcpInitializeRequest ( "/mcp" ) ;
120+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , "mcp_invalid_token_that_does_not_exist" ) ;
121+ using HttpResponseMessage response = await client . SendAsync ( request ) ;
122+ await Assert . That ( response . StatusCode ) . IsEqualTo ( HttpStatusCode . Unauthorized ) ;
123+ }
124+
125+ [ Test ]
126+ public async Task McpEndpoint_WithRevokedToken_Returns401 ( )
127+ {
128+ string testUserId = Guid . NewGuid ( ) . ToString ( ) ;
129+ string rawToken ;
130+ using ( var scope = factory . Services . CreateScope ( ) )
131+ {
132+ var db = scope . ServiceProvider . GetRequiredService < EssentialCSharpWebContext > ( ) ;
133+ db . Users . Add ( new EssentialCSharpWebUser
134+ {
135+ Id = testUserId ,
136+ UserName = $ "revoked-user-{ testUserId [ ..8 ] } ",
137+ NormalizedUserName = $ "REVOKED-USER-{ testUserId [ ..8 ] . ToUpperInvariant ( ) } ",
138+ Email = $ "revoked-{ testUserId [ ..8 ] } @example.com",
139+ NormalizedEmail = $ "REVOKED-{ testUserId [ ..8 ] . ToUpperInvariant ( ) } @EXAMPLE.COM",
140+ SecurityStamp = Guid . NewGuid ( ) . ToString ( ) ,
141+ } ) ;
142+ await db . SaveChangesAsync ( ) ;
143+
144+ var tokenService = scope . ServiceProvider . GetRequiredService < McpApiTokenService > ( ) ;
145+ ( rawToken , var entity ) = await tokenService . CreateTokenAsync ( testUserId , "revoke-test" ) ;
146+ await tokenService . RevokeTokenAsync ( entity . Id , testUserId ) ;
147+ }
148+
149+ HttpClient client = factory . CreateClient ( ) ;
150+ var request = CreateMcpInitializeRequest ( "/mcp" ) ;
151+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , rawToken ) ;
152+ using HttpResponseMessage response = await client . SendAsync ( request ) ;
153+ await Assert . That ( response . StatusCode ) . IsEqualTo ( HttpStatusCode . Unauthorized ) ;
154+ }
155+
156+ [ Test ]
157+ public async Task McpEndpoint_WithExpiredToken_Returns401 ( )
158+ {
159+ string testUserId = Guid . NewGuid ( ) . ToString ( ) ;
160+ string rawToken ;
161+ using ( var scope = factory . Services . CreateScope ( ) )
162+ {
163+ var db = scope . ServiceProvider . GetRequiredService < EssentialCSharpWebContext > ( ) ;
164+ db . Users . Add ( new EssentialCSharpWebUser
165+ {
166+ Id = testUserId ,
167+ UserName = $ "expired-user-{ testUserId [ ..8 ] } ",
168+ NormalizedUserName = $ "EXPIRED-USER-{ testUserId [ ..8 ] . ToUpperInvariant ( ) } ",
169+ Email = $ "expired-{ testUserId [ ..8 ] } @example.com",
170+ NormalizedEmail = $ "EXPIRED-{ testUserId [ ..8 ] . ToUpperInvariant ( ) } @EXAMPLE.COM",
171+ SecurityStamp = Guid . NewGuid ( ) . ToString ( ) ,
172+ } ) ;
173+ await db . SaveChangesAsync ( ) ;
174+
175+ var tokenService = scope . ServiceProvider . GetRequiredService < McpApiTokenService > ( ) ;
176+ // Create with an expiry in the past (1 second ago)
177+ DateTime pastExpiry = DateTime . UtcNow . AddSeconds ( - 1 ) ;
178+ ( rawToken , _ ) = await tokenService . CreateTokenAsync ( testUserId , "expired-test" , pastExpiry ) ;
179+ }
180+
181+ HttpClient client = factory . CreateClient ( ) ;
182+ var request = CreateMcpInitializeRequest ( "/mcp" ) ;
183+ request . Headers . Authorization = new AuthenticationHeaderValue ( "Bearer" , rawToken ) ;
184+ using HttpResponseMessage response = await client . SendAsync ( request ) ;
185+ await Assert . That ( response . StatusCode ) . IsEqualTo ( HttpStatusCode . Unauthorized ) ;
93186 }
94187
95188 private static HttpRequestMessage CreateMcpInitializeRequest ( string path )
0 commit comments