TaskAttachmentEntity (+config, cascade FK), TaskAttachmentRepository, and an AttachmentStore that writes files under ~/.todo-app/attachments/<taskId>/ with a path-traversal guard and a 5 MB cap. TaskPromptComposer gains an optional read-only 'Reference files' section. Migration AddTaskAttachments.
95 lines
2.6 KiB
C#
95 lines
2.6 KiB
C#
using ClaudeDo.Data;
|
|
|
|
namespace ClaudeDo.Data.Tests;
|
|
|
|
public sealed class AttachmentStoreTests : IDisposable
|
|
{
|
|
private readonly string _root;
|
|
private readonly AttachmentStore _store;
|
|
|
|
public AttachmentStoreTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), $"claudedo_att_{Guid.NewGuid():N}");
|
|
Directory.CreateDirectory(_root);
|
|
_store = new AttachmentStore(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(_root, recursive: true); } catch { }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Save_then_readback_size_matches()
|
|
{
|
|
var bytes = new byte[1024];
|
|
new Random(42).NextBytes(bytes);
|
|
using var ms = new MemoryStream(bytes);
|
|
|
|
var written = await _store.SaveAsync("task1", "data.bin", ms);
|
|
|
|
Assert.Equal(1024, written);
|
|
var filePath = Path.Combine(_store.TaskDir("task1"), "data.bin");
|
|
Assert.Equal(1024, new FileInfo(filePath).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rejects_fileName_with_dotdot()
|
|
{
|
|
using var ms = new MemoryStream(new byte[10]);
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
_store.SaveAsync("task1", "../escape.txt", ms));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rejects_fileName_with_directory_separator()
|
|
{
|
|
using var ms = new MemoryStream(new byte[10]);
|
|
await Assert.ThrowsAsync<ArgumentException>(() =>
|
|
_store.SaveAsync("task1", "sub/file.txt", ms));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rejects_content_over_5MB()
|
|
{
|
|
var over = new byte[5 * 1024 * 1024 + 1];
|
|
using var ms = new MemoryStream(over);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_store.SaveAsync("task1", "big.bin", ms));
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteTaskDir_removes_directory()
|
|
{
|
|
var dir = _store.TaskDir("task2");
|
|
Directory.CreateDirectory(dir);
|
|
File.WriteAllText(Path.Combine(dir, "a.txt"), "x");
|
|
|
|
_store.DeleteTaskDir("task2");
|
|
|
|
Assert.False(Directory.Exists(dir));
|
|
}
|
|
|
|
[Fact]
|
|
public void Deleting_missing_file_is_noop()
|
|
{
|
|
// Should not throw
|
|
_store.DeleteFile("taskX", "nonexistent.txt");
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteFile_removes_file_and_ignores_missing_second_call()
|
|
{
|
|
var dir = _store.TaskDir("task3");
|
|
Directory.CreateDirectory(dir);
|
|
var filePath = Path.Combine(dir, "remove.txt");
|
|
File.WriteAllText(filePath, "data");
|
|
|
|
_store.DeleteFile("task3", "remove.txt");
|
|
Assert.False(File.Exists(filePath));
|
|
|
|
// Second call must not throw
|
|
_store.DeleteFile("task3", "remove.txt");
|
|
}
|
|
}
|