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