From e7bf07c2ad15337c85ea1ff21721b8110b3b2406 Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Fri, 29 May 2026 18:18:28 -0700 Subject: [PATCH] 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 --- .gitignore | 1 + .../Services/LocalDiskFileStorageTests.cs | 52 ++++++++++++++++++ API/ROLAC.API/Program.cs | 2 + .../Services/Storage/IFileStorage.cs | 8 +++ .../Services/Storage/LocalDiskFileStorage.cs | 53 +++++++++++++++++++ API/ROLAC.API/appsettings.json | 3 ++ 6 files changed, 119 insertions(+) create mode 100644 API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs create mode 100644 API/ROLAC.API/Services/Storage/IFileStorage.cs create mode 100644 API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs diff --git a/.gitignore b/.gitignore index e1190fa..1ce5933 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,4 @@ logs/ *.temp /.claude /API/ROLAC.API/bin-verify +API/ROLAC.API/App_Data/ diff --git a/API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs b/API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs new file mode 100644 index 0000000..01ce67c --- /dev/null +++ b/API/ROLAC.API.Tests/Services/LocalDiskFileStorageTests.cs @@ -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 { ["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(() => fs.SaveAsync(input, "../escape.txt")); + } + + public void Dispose() + { + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + } +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 916cf2a..9a1148e 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -122,6 +122,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Swagger / MVC diff --git a/API/ROLAC.API/Services/Storage/IFileStorage.cs b/API/ROLAC.API/Services/Storage/IFileStorage.cs new file mode 100644 index 0000000..1098123 --- /dev/null +++ b/API/ROLAC.API/Services/Storage/IFileStorage.cs @@ -0,0 +1,8 @@ +namespace ROLAC.API.Services.Storage; + +public interface IFileStorage +{ + Task SaveAsync(Stream content, string relativePath, CancellationToken ct = default); + Task OpenReadAsync(string relativePath, CancellationToken ct = default); + Task DeleteAsync(string relativePath, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs b/API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs new file mode 100644 index 0000000..9ae145a --- /dev/null +++ b/API/ROLAC.API/Services/Storage/LocalDiskFileStorage.cs @@ -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 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 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; + } +} diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index b83d126..4151397 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -14,5 +14,8 @@ }, "Cors": { "AllowedOrigins": [ "http://localhost:4200", "https://localhost:4200" ] + }, + "Storage": { + "LocalRoot": "App_Data/storage" } }