Skip to content

Commit 67cb7fd

Browse files
cleanup
1 parent b206140 commit 67cb7fd

7 files changed

Lines changed: 236 additions & 39 deletions

File tree

EssentialCSharp.Chat.Shared/Extensions/ServiceCollectionExtensions.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,14 @@ public static class ServiceCollectionExtensions
1717

1818
/// <summary>
1919
/// Dispatches to <see cref="AddLocalAIServices"/> or <see cref="AddAzureOpenAIServices"/>
20-
/// based on <c>AIOptions:UseLocalAI</c>. Replaces the <c>if (!IsDevelopment())</c> guard in
21-
/// Program.cs so that AI services are always registered regardless of environment.
20+
/// based on <c>AIOptions:UseLocalAI</c>. AI chat requires either local AI mode
21+
/// or a configured Azure/Foundry endpoint in every environment.
2222
/// </summary>
2323
public static IHostApplicationBuilder AddAIServices(
2424
this IHostApplicationBuilder builder,
2525
IConfiguration configuration)
2626
{
27+
builder.Services.Configure<AIOptions>(configuration.GetSection("AIOptions"));
2728
var aiOptions = configuration.GetSection("AIOptions").Get<AIOptions>() ?? new AIOptions();
2829

2930
if (aiOptions.UseLocalAI)
@@ -34,14 +35,11 @@ public static IHostApplicationBuilder AddAIServices(
3435
{
3536
builder.Services.AddAzureOpenAIServices(configuration);
3637
}
37-
else if (!builder.Environment.IsDevelopment())
38+
else
3839
{
39-
// Non-development without an endpoint is a misconfiguration — fail loudly.
4040
throw new InvalidOperationException(
41-
"AIOptions:Endpoint is required when UseLocalAI=false in non-development environments. " +
42-
"Set the endpoint or enable local AI mode with aspire secret set Parameters:UseLocalAI true");
41+
"AI chat requires either AIOptions:UseLocalAI=true or AIOptions:Endpoint to be configured.");
4342
}
44-
// else: development + no config — graceful degradation, chat endpoints unavailable.
4543

4644
return builder;
4745
}

EssentialCSharp.Chat.Shared/Services/LocalAIChatService.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,14 @@ private void WarnUnsupportedFeatures(
9494
#pragma warning restore OPENAI001
9595
{
9696
if (tools is not null || reasoningEffortLevel is not null)
97+
{
9798
_logger.LogWarning("LocalAIChatService: ResponseTool and ReasoningEffortLevel are Azure-specific and are ignored in local mode.");
99+
}
98100

99101
if (enableContextualSearch)
102+
{
100103
_logger.LogWarning("LocalAIChatService: Vector search (RAG) is disabled in local mode (Phase 1). Run in Azure mode to enable contextual search.");
104+
}
101105
}
102106

103107
private List<ChatMessage> BuildMessages(string prompt, string? systemPrompt, string? previousResponseId)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
using EssentialCSharp.Chat.Common.Extensions;
2+
using EssentialCSharp.Chat.Common.Services;
3+
using Microsoft.Extensions.Configuration;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Hosting;
6+
7+
namespace EssentialCSharp.Chat.Tests;
8+
9+
public class ServiceCollectionExtensionsTests
10+
{
11+
[Test]
12+
public async Task AddAIServices_WhenDevelopmentWithoutConfiguration_ThrowsInvalidOperationException()
13+
{
14+
var builder = CreateBuilder(Environments.Development);
15+
16+
await Assert.That(() => builder.AddAIServices(builder.Configuration))
17+
.Throws<InvalidOperationException>();
18+
}
19+
20+
[Test]
21+
public async Task AddAIServices_WhenUseLocalAI_RegistersLocalAIService()
22+
{
23+
var builder = CreateBuilder(
24+
Environments.Development,
25+
new Dictionary<string, string?>
26+
{
27+
["AIOptions:UseLocalAI"] = bool.TrueString,
28+
["ConnectionStrings:ollama-chat"] = "Endpoint=http://localhost:11434;Model=qwen2.5-coder:7b"
29+
});
30+
31+
builder.AddAIServices(builder.Configuration);
32+
33+
var descriptor = builder.Services.LastOrDefault(service => service.ServiceType == typeof(IAIChatService));
34+
await Assert.That(descriptor).IsNotNull();
35+
await Assert.That(descriptor!.ImplementationType).IsEqualTo(typeof(LocalAIChatService));
36+
}
37+
38+
[Test]
39+
public async Task AddAIServices_WhenAzureEndpointConfigured_RegistersAzureAIService()
40+
{
41+
var builder = CreateBuilder(
42+
Environments.Production,
43+
new Dictionary<string, string?>
44+
{
45+
["AIOptions:Endpoint"] = "https://example.openai.azure.com/",
46+
["AIOptions:ChatDeploymentName"] = "chat",
47+
["AIOptions:VectorGenerationDeploymentName"] = "embeddings",
48+
["ConnectionStrings:PostgresVectorStore"] = "Host=test.postgres.database.azure.com;Database=app;Username=user"
49+
});
50+
51+
builder.AddAIServices(builder.Configuration);
52+
53+
await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(AIChatService))).IsTrue();
54+
await Assert.That(builder.Services.Any(service => service.ServiceType == typeof(IAIChatService))).IsTrue();
55+
}
56+
57+
[Test]
58+
public async Task AddAIServices_WhenProductionWithoutConfiguration_ThrowsInvalidOperationException()
59+
{
60+
var builder = CreateBuilder(Environments.Production);
61+
62+
await Assert.That(() => builder.AddAIServices(builder.Configuration))
63+
.Throws<InvalidOperationException>();
64+
}
65+
66+
private static HostApplicationBuilder CreateBuilder(
67+
string environmentName,
68+
Dictionary<string, string?>? settings = null)
69+
{
70+
var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings
71+
{
72+
EnvironmentName = environmentName
73+
});
74+
75+
builder.Configuration.Sources.Clear();
76+
builder.Configuration.AddInMemoryCollection(settings ?? []);
77+
return builder;
78+
}
79+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
using System.Security.Claims;
2+
using System.Text.Json;
3+
using EssentialCSharp.Chat.Common.Services;
4+
using EssentialCSharp.Web.Controllers;
5+
using EssentialCSharp.Web.Models;
6+
using EssentialCSharp.Web.Services;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.Mvc;
9+
using Microsoft.Extensions.Logging;
10+
using Moq;
11+
12+
namespace EssentialCSharp.Web.Tests;
13+
14+
public class ChatControllerTests
15+
{
16+
[Test]
17+
public async Task StreamMessage_MissingCaptchaToken_Returns403WithCaptchaRequired()
18+
{
19+
var controller = CreateController();
20+
21+
await controller.StreamMessage(new ChatMessageRequest { Message = "hello" });
22+
23+
var body = await ReadJsonResponse(controller.HttpContext.Response);
24+
await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status403Forbidden);
25+
await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_required");
26+
}
27+
28+
[Test]
29+
public async Task StreamMessage_InvalidCaptcha_Returns403WithCaptchaFailed()
30+
{
31+
var captchaService = new Mock<ICaptchaService>();
32+
captchaService
33+
.Setup(service => service.VerifyAsync("bad-token", It.IsAny<string?>(), It.IsAny<CancellationToken>()))
34+
.ReturnsAsync(new HCaptchaResult { Success = false });
35+
36+
var controller = CreateController(captchaService: captchaService.Object);
37+
38+
await controller.StreamMessage(new ChatMessageRequest { Message = "hello", CaptchaToken = "bad-token" });
39+
40+
var body = await ReadJsonResponse(controller.HttpContext.Response);
41+
await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status403Forbidden);
42+
await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_failed");
43+
}
44+
45+
[Test]
46+
public async Task StreamMessage_CaptchaServiceUnavailable_Returns503WithCaptchaUnavailable()
47+
{
48+
var captchaService = new Mock<ICaptchaService>();
49+
captchaService
50+
.Setup(service => service.VerifyAsync("token", It.IsAny<string?>(), It.IsAny<CancellationToken>()))
51+
.ReturnsAsync((HCaptchaResult?)null);
52+
53+
var controller = CreateController(captchaService: captchaService.Object);
54+
55+
await controller.StreamMessage(new ChatMessageRequest { Message = "hello", CaptchaToken = "token" });
56+
57+
var body = await ReadJsonResponse(controller.HttpContext.Response);
58+
await Assert.That(controller.HttpContext.Response.StatusCode).IsEqualTo(StatusCodes.Status503ServiceUnavailable);
59+
await Assert.That(body["errorCode"].GetString()).IsEqualTo("captcha_unavailable");
60+
}
61+
62+
private static ChatController CreateController(
63+
IAIChatService? aiChatService = null,
64+
ICaptchaService? captchaService = null)
65+
{
66+
var httpContext = new DefaultHttpContext
67+
{
68+
User = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.Name, "test-user")], "TestAuth"))
69+
};
70+
httpContext.Response.Body = new MemoryStream();
71+
72+
var controller = new ChatController(
73+
Mock.Of<ILogger<ChatController>>(),
74+
aiChatService ?? new Mock<IAIChatService>(MockBehavior.Strict).Object,
75+
captchaService ?? new Mock<ICaptchaService>(MockBehavior.Strict).Object)
76+
{
77+
ControllerContext = new ControllerContext { HttpContext = httpContext }
78+
};
79+
80+
return controller;
81+
}
82+
83+
private static async Task<Dictionary<string, JsonElement>> ReadJsonResponse(HttpResponse response)
84+
{
85+
response.Body.Position = 0;
86+
using var reader = new StreamReader(response.Body, leaveOpen: true);
87+
var json = await reader.ReadToEndAsync();
88+
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)!;
89+
}
90+
}

EssentialCSharp.Web/Controllers/ChatController.cs

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -132,30 +132,44 @@ await Response.WriteAsJsonAsync(
132132
}
133133

134134
/// <summary>
135-
/// Verifies the hCaptcha token. Fails-open on service outage (returns success with warning)
136-
/// since the endpoint is already protected by [Authorize] and rate limiting.
135+
/// Verifies the hCaptcha token and denies chat access when verification cannot be completed.
137136
/// </summary>
138137
private async Task<(bool Success, IActionResult? Error)> VerifyCaptchaAsync(
139138
string? captchaToken, CancellationToken cancellationToken)
140139
{
141140
if (string.IsNullOrWhiteSpace(captchaToken))
142-
return (false, StatusCode(StatusCodes.Status403Forbidden,
143-
new { error = "Captcha verification required.", errorCode = "captcha_required", retryable = true }));
141+
return (false, CreateCaptchaRequiredResult());
144142

145143
var remoteIp = HttpContext.Connection.RemoteIpAddress?.ToString();
146144
var result = await _CaptchaService.VerifyAsync(captchaToken, remoteIp, cancellationToken);
147145

148146
if (result is null)
149147
{
150-
// hCaptcha service is unreachable — fail-open since [Authorize] + rate limiting still protect the endpoint.
151-
_Logger.LogWarning("hCaptcha service unavailable for user {User} — allowing request", User.Identity?.Name);
152-
return (true, null);
148+
_Logger.LogWarning("hCaptcha service unavailable for user {User} — denying request", User.Identity?.Name);
149+
return (false, CreateCaptchaUnavailableResult());
153150
}
154151

155152
if (!result.Success)
156-
return (false, StatusCode(StatusCodes.Status403Forbidden,
157-
new { error = "Captcha verification failed.", errorCode = "captcha_failed", retryable = true }));
153+
return (false, CreateCaptchaFailedResult());
158154

159155
return (true, null);
160156
}
157+
158+
private ObjectResult CreateCaptchaRequiredResult() =>
159+
StatusCode(StatusCodes.Status403Forbidden,
160+
new { error = "Captcha verification required.", errorCode = "captcha_required", retryable = true });
161+
162+
private ObjectResult CreateCaptchaFailedResult() =>
163+
StatusCode(StatusCodes.Status403Forbidden,
164+
new { error = "Captcha verification failed.", errorCode = "captcha_failed", retryable = true });
165+
166+
private ObjectResult CreateCaptchaUnavailableResult() =>
167+
StatusCode(StatusCodes.Status503ServiceUnavailable,
168+
new
169+
{
170+
error = "Captcha verification is temporarily unavailable. Please try again later.",
171+
errorCode = "captcha_unavailable",
172+
retryable = true
173+
});
174+
161175
}

EssentialCSharp.Web/Program.cs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,11 +244,10 @@ private static void Main(string[] args)
244244
// AIOptions__UseLocalAI=true enables Ollama local mode (set via aspire secret or dashboard).
245245
builder.AddAIServices(configuration);
246246

247-
// When using local Ollama, Polly's default 30s TotalRequestTimeout fires before LLM inference
248-
// completes (qwen2.5-coder:7b consistently takes >30s). Override globally — this code path
249-
// is only reached in local dev when UseLocalAI=true, so widening all clients is acceptable.
247+
// When using local Ollama in development, Polly's default 30s TotalRequestTimeout fires
248+
// before LLM inference completes (qwen2.5-coder:7b consistently takes >30s).
250249
var aiOptsForTimeout = configuration.GetSection("AIOptions").Get<EssentialCSharp.Chat.AIOptions>();
251-
if (aiOptsForTimeout?.UseLocalAI == true)
250+
if (builder.Environment.IsDevelopment() && aiOptsForTimeout?.UseLocalAI == true)
252251
{
253252
builder.Services.PostConfigureAll<HttpStandardResilienceOptions>(options =>
254253
{

0 commit comments

Comments
 (0)