feat(church-profile): masked-read + leave-unchanged write for AI keys
This commit is contained in:
@@ -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<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ public class ChurchProfileDto
|
|||||||
public string? BankAccountNumber { get; set; }
|
public string? BankAccountNumber { get; set; }
|
||||||
public string? BankRoutingNumber { get; set; }
|
public string? BankRoutingNumber { get; set; }
|
||||||
public int NextCheckNumber { 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
|
public class UpdateChurchProfileRequest
|
||||||
@@ -34,4 +39,9 @@ public class UpdateChurchProfileRequest
|
|||||||
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
[MaxLength(50)] public string? BankAccountNumber { get; set; }
|
||||||
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
[MaxLength(50)] public string? BankRoutingNumber { get; set; }
|
||||||
[Range(1, int.MaxValue)] public int NextCheckNumber { 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
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.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.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
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();
|
await _db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,4 +54,12 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
}
|
}
|
||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Mask a stored secret for display: 6 bullets + last 4 chars; fully masked when ≤4 chars.</summary>
|
||||||
|
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..];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user