diff --git a/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs b/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
new file mode 100644
index 000000000..226b8e72a
--- /dev/null
+++ b/src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
@@ -0,0 +1,303 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Blog;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+///
+/// Integration tests for the estimate attachment endpoints
+/// (GET/POST/DELETE api/v1/estimate/{id}/attachments), which
+/// attach a file from the provider's personal storage to an estimate
+/// **by reference** ().
+///
+/// Coverage mirrors the authorization contract documented on the
+/// controller: only the provider (estimate.OwnerId) may attach or
+/// detach, the file must belong to the caller, and listing is open to
+/// both parties (provider/client) plus admin. A third party is refused.
+/// The download of the bytes themselves is served by the Blogs host and
+/// exercised in Yavsc.Blogs.Tests; these tests cover only the
+/// metadata/relationship endpoints that live on the API host.
+///
+[Collection("Yavsc Api")]
+public sealed class EstimateAttachmentsApiTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public EstimateAttachmentsApiTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ ///
+ /// Reset the graph (seeding alice the provider and bob the client),
+ /// create an estimate owned by alice for bob, and seed two personal
+ /// rows: one owned by alice (the provider's
+ /// own file) and one owned by bob (a file that does NOT belong to the
+ /// provider, used to assert the ownership guard on attach).
+ ///
+ private (long estimateId, long aliceFileId, long bobFileId) Seed()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var estimate = new Estimate
+ {
+ ClientId = "bob",
+ OwnerId = "alice",
+ CommandType = BillingCodes.Rdv,
+ Title = "Devis prestation",
+ Description = "Devis de test",
+ AttachedFiles = new List(),
+ AttachedGraphics = new List(),
+ Bill = new List
+ {
+ new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
+ },
+ };
+ db.Estimates.Add(estimate);
+ db.SaveChanges();
+
+ var aliceFile = new UploadedFile
+ {
+ OwnerId = "alice",
+ Path = "provider-doc.pdf",
+ ContentType = "application/pdf",
+ Length = 2048,
+ };
+ var bobFile = new UploadedFile
+ {
+ OwnerId = "bob",
+ Path = "client-note.txt",
+ ContentType = "text/plain",
+ Length = 64,
+ };
+ db.UploadedFiles.AddRange(aliceFile, bobFile);
+ db.SaveChanges();
+
+ return (estimate.Id, aliceFile.Id, bobFile.Id);
+ }
+
+ private int CountAttachments(long estimateId)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
+ }
+
+ [Fact]
+ public async Task Provider_attaching_own_file_creates_the_link()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var http = NewClient("alice");
+ var response = await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ Assert.Equal(1, CountAttachments(estimateId));
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
+ Assert.Equal(aliceFileId, link.FileId);
+ }
+
+ [Fact]
+ public async Task Attaching_the_same_file_twice_is_idempotent()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var http = NewClient("alice");
+ await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+ var second = await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, second.StatusCode);
+ Assert.Equal(1, CountAttachments(estimateId));
+ }
+
+ [Fact]
+ public async Task Non_provider_cannot_attach_to_an_estimate()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ // bob is the client of the estimate, not the provider.
+ using var http = NewClient("bob");
+ var response = await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(0, CountAttachments(estimateId));
+ }
+
+ [Fact]
+ public async Task Provider_cannot_attach_a_file_they_do_not_own()
+ {
+ var (estimateId, _, bobFileId) = Seed();
+
+ // alice is the provider, but the file belongs to bob.
+ using var http = NewClient("alice");
+ var response = await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(0, CountAttachments(estimateId));
+ }
+
+ [Fact]
+ public async Task Provider_detaches_a_file()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var http = NewClient("alice");
+ await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(1, CountAttachments(estimateId));
+
+ var response = await http.DeleteAsync(
+ $"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(0, CountAttachments(estimateId));
+ }
+
+ [Fact]
+ public async Task Non_provider_cannot_detach()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var http = NewClient("alice");
+ await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ using var bob = NewClient("bob");
+ var response = await bob.DeleteAsync(
+ $"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(1, CountAttachments(estimateId));
+ }
+
+ [Fact]
+ public async Task Provider_lists_attachments()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var http = NewClient("alice");
+ await http.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ var response = await http.GetAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var item = Assert.Single(doc.RootElement.EnumerateArray());
+ Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
+ Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
+ Assert.Equal("alice", item.GetProperty("ownerId").GetString());
+ }
+
+ [Fact]
+ public async Task Client_can_list_attachments_as_a_party()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var alice = NewClient("alice");
+ await alice.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ // bob is the client of the estimate: a party, may list.
+ using var bob = NewClient("bob");
+ var response = await bob.GetAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Third_party_cannot_list_attachments()
+ {
+ var (estimateId, aliceFileId, _) = Seed();
+
+ using var alice = NewClient("alice");
+ await alice.PostAsJsonAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ // carol is neither the provider nor the client.
+ using var carol = NewClient("carol");
+ var response = await carol.GetAsync(
+ $"/api/v1/estimate/{estimateId}/attachments",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Listing_an_unknown_estimate_returns_404()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.GetAsync(
+ "/api/v1/estimate/999999/attachments",
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs b/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
new file mode 100644
index 000000000..d4438ef4e
--- /dev/null
+++ b/src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
@@ -0,0 +1,256 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Helpers;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Blog;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+///
+/// Integration tests for the query (demande) attachment endpoints
+/// (GET/POST/DELETE api/v1/front/query/{queryId}/attachments),
+/// which attach a file from the client's personal storage to their
+/// NominativeServiceCommand **by reference**
+/// ().
+///
+/// Coverage mirrors the controller contract: only the client
+/// (the demande's ClientId) may attach or detach, the file must
+/// belong to the caller, and listing is open to both parties
+/// (client/provider) plus admin. A third party is refused.
+///
+[Collection("Yavsc Api")]
+public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "bob", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ ///
+ /// Seed an Rdv query from bob (client) to alice (provider) and two
+ /// personal rows: one owned by bob (the
+ /// client's own file) and one owned by alice (a file that does NOT
+ /// belong to the client, used to assert the ownership guard).
+ ///
+ private (long queryId, long bobFileId, long aliceFileId) Seed()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedActivityGraph();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var location = db.Locations.Single(l => l.Address == "1 rue du Test");
+
+ var query = new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "bob",
+ UserModified = "bob",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Demande de devis",
+ Status = QueryStatus.InProgress,
+ Description = "Demande en attente",
+ };
+ db.RdvQueries.Add(query);
+ db.SaveChanges();
+
+ var bobFile = new UploadedFile
+ {
+ OwnerId = "bob",
+ Path = "demande-photo.jpg",
+ ContentType = "image/jpeg",
+ Length = 4096,
+ };
+ var aliceFile = new UploadedFile
+ {
+ OwnerId = "alice",
+ Path = "provider-spec.pdf",
+ ContentType = "application/pdf",
+ Length = 1024,
+ };
+ db.UploadedFiles.AddRange(bobFile, aliceFile);
+ db.SaveChanges();
+
+ return (query.Id, bobFile.Id, aliceFile.Id);
+ }
+
+ private int CountAttachments(long queryId)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
+ }
+
+ private string AttachUrl(long queryId) =>
+ $"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
+
+ private string DetachUrl(long queryId, long fileId) =>
+ $"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
+
+ [Fact]
+ public async Task Client_attaching_own_file_creates_the_link()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ using var http = NewClient("bob");
+ var response = await http.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ Assert.Equal(1, CountAttachments(queryId));
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
+ Assert.Equal(bobFileId, link.FileId);
+ }
+
+ [Fact]
+ public async Task Provider_cannot_attach_to_a_query()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ // alice is the provider of the query; only the client attaches.
+ using var http = NewClient("alice");
+ var response = await http.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(0, CountAttachments(queryId));
+ }
+
+ [Fact]
+ public async Task Client_cannot_attach_a_file_they_do_not_own()
+ {
+ var (queryId, _, aliceFileId) = Seed();
+
+ // bob is the client, but the file belongs to alice.
+ using var http = NewClient("bob");
+ var response = await http.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = aliceFileId },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(0, CountAttachments(queryId));
+ }
+
+ [Fact]
+ public async Task Client_detaches_a_file()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ using var http = NewClient("bob");
+ await http.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(1, CountAttachments(queryId));
+
+ var response = await http.DeleteAsync(
+ DetachUrl(queryId, bobFileId),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal(0, CountAttachments(queryId));
+ }
+
+ [Fact]
+ public async Task Provider_cannot_detach()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ using var bob = NewClient("bob");
+ await bob.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+
+ using var alice = NewClient("alice");
+ var response = await alice.DeleteAsync(
+ DetachUrl(queryId, bobFileId),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ Assert.Equal(1, CountAttachments(queryId));
+ }
+
+ [Fact]
+ public async Task Provider_lists_attachments_as_a_party()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ using var bob = NewClient("bob");
+ await bob.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+
+ // alice is the provider: a party, may list.
+ using var alice = NewClient("alice");
+ var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var item = Assert.Single(doc.RootElement.EnumerateArray());
+ Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
+ Assert.Equal("bob", item.GetProperty("ownerId").GetString());
+ }
+
+ [Fact]
+ public async Task Third_party_cannot_list_attachments()
+ {
+ var (queryId, bobFileId, _) = Seed();
+
+ using var bob = NewClient("bob");
+ await bob.PostAsJsonAsync(
+ AttachUrl(queryId),
+ new { fileId = bobFileId },
+ TestContext.Current.CancellationToken);
+
+ using var carol = NewClient("carol");
+ var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
new file mode 100644
index 000000000..b5eeb8e00
--- /dev/null
+++ b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
@@ -0,0 +1,294 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Blogs.Tests.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Blog;
+using Yavsc.Models.Relationship;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Blogs.Tests;
+
+///
+/// Integration tests for the personal-storage file endpoints
+/// (api/v1/fs) served by FileSystemApiController on the
+/// Blogs host.
+///
+/// Two concerns:
+///
+/// - Upload records metadata: a multipart
+/// POST api/v1/fs persists an row
+/// owned by the caller and returns its fileId, so the client
+/// can later attach that file to an estimate/query by
+/// reference.
+/// - Download authorization:
+/// GET api/v1/fs/file/{fileId} serves the bytes from the
+/// owner's personal space. The owner may always read; a party
+/// (client or provider) of an estimate/query the file is attached
+/// to may read; a third party is refused. The bytes live on the
+/// Blogs host's disk — this is the cross-container reason the
+/// download endpoint lives here and not on the API host.
+///
+///
+[Collection("Yavsc Blogs")]
+public sealed class FileSystemApiTests : IClassFixture
+{
+ private readonly BlogsWebServerFixture _fixture;
+
+ public FileSystemApiTests(BlogsWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
+ return http;
+ }
+
+ private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
+ Path.Combine(fixture.BlogFilesRoot, owner, fileName);
+
+ /// Write a physical file into the owner's personal space,
+ /// so GetFile can stream it back. Creates the directory if
+ /// needed.
+ private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
+ {
+ var path = PhysicalFile(fixture, owner, fileName);
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ File.WriteAllBytes(path, content);
+ }
+
+ private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var file = new UploadedFile
+ {
+ OwnerId = ownerId,
+ Path = path,
+ ContentType = contentType,
+ Length = length,
+ };
+ db.UploadedFiles.Add(file);
+ db.SaveChanges();
+ return file.Id;
+ }
+
+ [Fact]
+ public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
+ {
+ _fixture.ResetDatabase();
+ // DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
+ // once usage >= DiskQuota, and the controller skips the metadata
+ // row when QuotaOffense is set. A fresh user has quota 0.
+ _fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
+
+ var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
+
+ using var form = new MultipartFormDataContent();
+ var fileContent = new ByteArrayContent(payload);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
+ form.Add(fileContent, "file", "note.txt");
+
+ using var http = NewClient("alice");
+ var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var item = Assert.Single(doc.RootElement.EnumerateArray());
+ var fileId = item.GetProperty("fileId").GetInt64();
+ Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
+ Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
+ Assert.False(item.GetProperty("quotaOffense").GetBoolean());
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
+ Assert.Equal("note.txt", row.Path);
+ Assert.Equal("text/plain", row.ContentType);
+ Assert.Equal(payload.Length, row.Length);
+
+ // The file physically landed under {BlogFilesRoot}/alice/note.txt.
+ Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
+ "Expected the uploaded file on disk under the owner's personal root");
+ }
+
+ [Fact]
+ public async Task GetFile_owner_can_download_their_own_file()
+ {
+ _fixture.ResetDatabase();
+ _fixture.SeedUser("alice");
+
+ var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
+ WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
+ var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
+
+ using var http = NewClient("alice");
+ var response = await http.GetAsync(
+ $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(bytes, downloaded);
+ }
+
+ [Fact]
+ public async Task GetFile_refuses_a_third_party()
+ {
+ _fixture.ResetDatabase();
+ _fixture.SeedUser("alice");
+ _fixture.SeedUser("carol");
+
+ var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
+ WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
+ var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
+
+ // carol is neither the owner nor a party to any estimate/query
+ // this file is attached to.
+ using var http = NewClient("carol");
+ var response = await http.GetAsync(
+ $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ }
+
+ ///
+ /// Seed the graph needed to prove a party (the client of an
+ /// estimate the file is attached to) can download a file they do
+ /// not own: alice owns the file and is the provider; bob is the
+ /// client; the file is attached to the estimate by reference.
+ ///
+ private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
+ {
+ _fixture.ResetDatabase();
+ _fixture.SeedUser("alice");
+ _fixture.SeedUser("bob");
+
+ fileName = "party-spec.pdf";
+ var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // Location + PerformerProfile(alice): Estimate.OwnerId is a FK
+ // to PerformerProfile.PerformerId, so the provider profile must
+ // exist before the estimate can be inserted.
+ var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
+ db.Locations.Add(location);
+ db.SaveChanges();
+
+ db.Performers.Add(new PerformerProfile
+ {
+ PerformerId = "alice",
+ SIREN = "12345678901234",
+ OrganizationAddressId = location.Id,
+ ExerciseCountryCode = "fr",
+ AcceptNotifications = true,
+ AcceptPublicContact = true,
+ Active = true,
+ Rate = 5,
+ WebSite = "https://alice.dev",
+ });
+ db.SaveChanges();
+
+ var estimate = new Estimate
+ {
+ OwnerId = "alice",
+ ClientId = "bob",
+ CommandType = "Rdv",
+ Title = "Devis party test",
+ Description = "Devis",
+ AttachedFiles = new List(),
+ AttachedGraphics = new List(),
+ Bill = new List
+ {
+ new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
+ },
+ };
+ db.Estimates.Add(estimate);
+ db.SaveChanges();
+ estimateId = estimate.Id;
+
+ var file = new UploadedFile
+ {
+ OwnerId = "alice",
+ Path = fileName,
+ ContentType = "application/pdf",
+ Length = bytes.Length,
+ };
+ db.UploadedFiles.Add(file);
+ db.SaveChanges();
+
+ db.EstimateAttachedFiles.Add(new EstimateAttachedFile
+ {
+ FileId = file.Id,
+ EstimateId = estimate.Id,
+ });
+ db.SaveChanges();
+
+ // The bytes must exist on disk for GetFile to stream them.
+ WritePhysicalFile(_fixture, "alice", fileName, bytes);
+ return file.Id;
+ }
+
+ [Fact]
+ public async Task GetFile_party_client_of_an_attached_estimate_can_download()
+ {
+ var fileId = SeedPartyDownloadGraph(out _, out _);
+
+ // bob is the client of the estimate the file is attached to:
+ // a party, authorized to read even though alice owns the file.
+ using var http = NewClient("bob");
+ var response = await http.GetAsync(
+ $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK,
+ $"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
+ var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
+ }
+
+ [Fact]
+ public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
+ {
+ var fileId = SeedPartyDownloadGraph(out _, out _);
+
+ // alice is the owner too, so this also exercises the owner path;
+ // the provider of an attached estimate is authorized regardless.
+ using var http = NewClient("alice");
+ var response = await http.GetAsync(
+ $"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task GetFile_unknown_id_returns_404()
+ {
+ _fixture.ResetDatabase();
+ _fixture.SeedUser("alice");
+
+ using var http = NewClient("alice");
+ var response = await http.GetAsync(
+ "/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+}
\ No newline at end of file