diff --git a/API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs b/API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs new file mode 100644 index 0000000..034acf0 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ChurchProfileServiceTests.cs @@ -0,0 +1,103 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.DTOs.Disbursement; +using ROLAC.API.Entities; +using ROLAC.API.Services; +using ROLAC.API.Services.Logging; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ChurchProfileServiceTests +{ + // ChurchProfile is auditable, so the InMemory store rejects saves unless the + // required CreatedBy/UpdatedBy fields are populated. Wire the same audit + // interceptor the app uses so seeded entities save cleanly. + private static AppDbContext NewDb() + { + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })), + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object))) + .Options); + } + + private static UpdateChurchProfileRequest Req( + string provider = "Claude", string? claudeKey = null, string? geminiKey = null, + string? claudeModel = "m", string? geminiModel = "m") => + new() + { + Name = "C", NextCheckNumber = 1001, AiProvider = provider, + ClaudeModel = claudeModel, GeminiModel = geminiModel, + ClaudeApiKey = claudeKey, GeminiApiKey = geminiKey, + }; + + [Fact] + public async Task GetAsync_masks_stored_api_keys() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile + { + Name = "C", ClaudeApiKey = "sk-ant-abcd1234", GeminiApiKey = "AIzaXYZ9876", + }); + await db.SaveChangesAsync(); + + var dto = await new ChurchProfileService(db).GetAsync(); + + Assert.Equal("••••••1234", dto.ClaudeApiKeyMasked); + Assert.Equal("••••••9876", dto.GeminiApiKeyMasked); + } + + [Fact] + public async Task UpdateAsync_blank_key_keeps_existing() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: null)); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("sk-keep-0001", p.ClaudeApiKey); + } + + [Fact] + public async Task UpdateAsync_nonblank_key_replaces() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", ClaudeApiKey = "sk-keep-0001" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync(Req(claudeKey: "sk-new-9999")); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("sk-new-9999", p.ClaudeApiKey); + } + + [Fact] + public async Task UpdateAsync_sets_provider_and_models() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C" }); + await db.SaveChangesAsync(); + + await new ChurchProfileService(db).UpdateAsync( + Req(provider: "Gemini", claudeModel: "claude-x", geminiModel: "gemini-y")); + + var p = await db.ChurchProfiles.FirstAsync(); + Assert.Equal("Gemini", p.AiProvider); + Assert.Equal("claude-x", p.ClaudeModel); + Assert.Equal("gemini-y", p.GeminiModel); + } +} diff --git a/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs index 8a18e3c..749e8c4 100644 --- a/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs +++ b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs @@ -17,6 +17,11 @@ public class ChurchProfileDto public string? BankAccountNumber { get; set; } public string? BankRoutingNumber { get; set; } public int NextCheckNumber { get; set; } + public string AiProvider { get; set; } = "Claude"; + public string? ClaudeModel { get; set; } + public string? ClaudeApiKeyMasked { get; set; } + public string? GeminiModel { get; set; } + public string? GeminiApiKeyMasked { get; set; } } public class UpdateChurchProfileRequest @@ -34,4 +39,9 @@ public class UpdateChurchProfileRequest [MaxLength(50)] public string? BankAccountNumber { get; set; } [MaxLength(50)] public string? BankRoutingNumber { get; set; } [Range(1, int.MaxValue)] public int NextCheckNumber { get; set; } + [MaxLength(20)] public string AiProvider { get; set; } = "Claude"; + [MaxLength(100)] public string? ClaudeModel { get; set; } + [MaxLength(500)] public string? ClaudeApiKey { get; set; } // null/blank = leave unchanged + [MaxLength(100)] public string? GeminiModel { get; set; } + [MaxLength(500)] public string? GeminiApiKey { get; set; } // null/blank = leave unchanged } diff --git a/API/ROLAC.API/Services/ChurchProfileService.cs b/API/ROLAC.API/Services/ChurchProfileService.cs index 982ecaa..842dbf6 100644 --- a/API/ROLAC.API/Services/ChurchProfileService.cs +++ b/API/ROLAC.API/Services/ChurchProfileService.cs @@ -19,6 +19,11 @@ public class ChurchProfileService : IChurchProfileService Website = p.Website, Address = p.Address, City = p.City, State = p.State, ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber, BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber, + AiProvider = p.AiProvider, + ClaudeModel = p.ClaudeModel, + ClaudeApiKeyMasked = Mask(p.ClaudeApiKey), + GeminiModel = p.GeminiModel, + GeminiApiKeyMasked = Mask(p.GeminiApiKey), }; } @@ -29,6 +34,12 @@ public class ChurchProfileService : IChurchProfileService p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State; p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber; p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber; + p.AiProvider = string.IsNullOrWhiteSpace(r.AiProvider) ? "Claude" : r.AiProvider; + p.ClaudeModel = r.ClaudeModel; + p.GeminiModel = r.GeminiModel; + // Leave-unchanged semantics: only overwrite a stored key when a new value is supplied. + if (!string.IsNullOrWhiteSpace(r.ClaudeApiKey)) p.ClaudeApiKey = r.ClaudeApiKey; + if (!string.IsNullOrWhiteSpace(r.GeminiApiKey)) p.GeminiApiKey = r.GeminiApiKey; await _db.SaveChangesAsync(); } @@ -43,4 +54,12 @@ public class ChurchProfileService : IChurchProfileService } return p; } + + /// Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars. + private static string Mask(string? key) + { + if (string.IsNullOrEmpty(key)) return ""; + if (key.Length <= 4) return new string('•', key.Length); + return new string('•', 6) + key[^4..]; + } }