feat(storage): add IFileStorage + local-disk implementation

Adds IFileStorage abstraction and LocalDiskFileStorage for receipt file storage with path-traversal protection, and registers it in DI. Includes 3 TDD-verified xUnit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-05-29 18:18:28 -07:00
parent ac65c68e18
commit e7bf07c2ad
6 changed files with 119 additions and 0 deletions
+1
View File
@@ -93,3 +93,4 @@ logs/
*.temp
/.claude
/API/ROLAC.API/bin-verify
API/ROLAC.API/App_Data/
@@ -0,0 +1,52 @@
using System.Text;
using Microsoft.Extensions.Configuration;
using ROLAC.API.Services.Storage;
using Xunit;
namespace ROLAC.API.Tests.Services;
public class LocalDiskFileStorageTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "rolac-test-" + Guid.NewGuid());
private LocalDiskFileStorage Build()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:LocalRoot"] = _root })
.Build();
return new LocalDiskFileStorage(config);
}
[Fact]
public async Task SaveThenOpen_RoundTrips()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("hello"));
var path = await fs.SaveAsync(input, "finance/receipts/2026/5/1-r.txt");
await using var read = await fs.OpenReadAsync(path);
Assert.NotNull(read);
using var sr = new StreamReader(read!);
Assert.Equal("hello", await sr.ReadToEndAsync());
}
[Fact]
public async Task OpenRead_ReturnsNull_WhenMissing()
{
var fs = Build();
Assert.Null(await fs.OpenReadAsync("finance/receipts/none.txt"));
}
[Fact]
public async Task Save_RejectsPathTraversal()
{
var fs = Build();
using var input = new MemoryStream(Encoding.UTF8.GetBytes("x"));
await Assert.ThrowsAsync<ArgumentException>(() => fs.SaveAsync(input, "../escape.txt"));
}
public void Dispose()
{
if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true);
}
}
+2
View File
@@ -122,6 +122,8 @@ builder.Services.AddScoped<IGivingCategoryService, GivingCategoryService>();
builder.Services.AddScoped<IGivingService, GivingService>();
builder.Services.AddScoped<IOfferingSessionService, OfferingSessionService>();
builder.Services.AddScoped<IMinistryService, MinistryService>();
builder.Services.AddScoped<ROLAC.API.Services.Storage.IFileStorage,
ROLAC.API.Services.Storage.LocalDiskFileStorage>();
// ---------------------------------------------------------------------------
// Swagger / MVC
@@ -0,0 +1,8 @@
namespace ROLAC.API.Services.Storage;
public interface IFileStorage
{
Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default);
Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default);
Task DeleteAsync(string relativePath, CancellationToken ct = default);
}
@@ -0,0 +1,53 @@
using Microsoft.Extensions.Configuration;
namespace ROLAC.API.Services.Storage;
public class LocalDiskFileStorage : IFileStorage
{
private readonly string _root;
public LocalDiskFileStorage(IConfiguration config)
{
var configured = config["Storage:LocalRoot"] ?? "App_Data/storage";
_root = Path.IsPathRooted(configured)
? configured
: Path.Combine(Directory.GetCurrentDirectory(), configured);
Directory.CreateDirectory(_root);
}
private string Resolve(string relativePath)
{
if (string.IsNullOrWhiteSpace(relativePath))
throw new ArgumentException("relativePath is required.", nameof(relativePath));
var normalized = relativePath.Replace('\\', '/').TrimStart('/');
var full = Path.GetFullPath(Path.Combine(_root, normalized));
var rootFull = Path.GetFullPath(_root) + Path.DirectorySeparatorChar;
if (!full.StartsWith(rootFull, StringComparison.Ordinal))
throw new ArgumentException("Path escapes storage root.", nameof(relativePath));
return full;
}
public async Task<string> SaveAsync(Stream content, string relativePath, CancellationToken ct = default)
{
var full = Resolve(relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
await using var file = File.Create(full);
await content.CopyToAsync(file, ct);
return relativePath.Replace('\\', '/').TrimStart('/');
}
public Task<Stream?> OpenReadAsync(string relativePath, CancellationToken ct = default)
{
var full = Resolve(relativePath);
Stream? result = File.Exists(full) ? File.OpenRead(full) : null;
return Task.FromResult(result);
}
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
{
var full = Resolve(relativePath);
if (File.Exists(full)) File.Delete(full);
return Task.CompletedTask;
}
}
+3
View File
@@ -14,5 +14,8 @@
},
"Cors": {
"AllowedOrigins": [ "http://localhost:4200", "https://localhost:4200" ]
},
"Storage": {
"LocalRoot": "App_Data/storage"
}
}