Tests d'attachement (devis/demande) et de l'espace de stockage perso
Couverture des endpoints backend introduits pour la gestion des fichiers
utilisateur et des pièces jointes par référence.
Yavsc.Api.Test:
- EstimateAttachmentsApiTests : attachement de fichiers au devis
(fournisseur uniquement, fichier devant appartenir au caller,
idempotence du re-attach, détachement, listage ouvert aux deux
parties + admin, tiers refusé, 404 sur devis inconnu).
- FrontOfficeQueryAttachmentsApiTests : attachement à la demande
(client uniquement, fichier devant appartenir au caller, détachement,
listage ouvert aux deux parties, tiers refusé).
Yavsc.Blogs.Tests:
- FileSystemApiTests : POST api/v1/fs crée une ligne UploadedFile
propriétaire (avec FileId en retour) et écrit le fichier sur disque ;
GET api/v1/fs/file/{fileId} autorise le propriétaire et une partie
au devis rattaché (client/fournisseur), refuse un tiers, 404 sur id
inconnu.
306/306 verts.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
70ab05522b
commit
8e3862ed26
3 changed files with 853 additions and 0 deletions
303
src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
Normal file
303
src/Yavsc.Api.Test/EstimateAttachmentsApiTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the estimate attachment endpoints
|
||||||
|
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
|
||||||
|
/// attach a file from the provider's personal storage to an estimate
|
||||||
|
/// **by reference** (<see cref="EstimateAttachedFile"/>).
|
||||||
|
///
|
||||||
|
/// <para>Coverage mirrors the authorization contract documented on the
|
||||||
|
/// controller: only the provider (<c>estimate.OwnerId</c>) 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 <c>Yavsc.Blogs.Tests</c>; these tests cover only the
|
||||||
|
/// metadata/relationship endpoints that live on the API host.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reset the graph (seeding alice the provider and bob the client),
|
||||||
|
/// create an estimate owned by alice for bob, and seed two personal
|
||||||
|
/// <see cref="UploadedFile"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
private (long estimateId, long aliceFileId, long bobFileId) Seed()
|
||||||
|
{
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
|
||||||
|
var estimate = new Estimate
|
||||||
|
{
|
||||||
|
ClientId = "bob",
|
||||||
|
OwnerId = "alice",
|
||||||
|
CommandType = BillingCodes.Rdv,
|
||||||
|
Title = "Devis prestation",
|
||||||
|
Description = "Devis de test",
|
||||||
|
AttachedFiles = new List<string>(),
|
||||||
|
AttachedGraphics = new List<string>(),
|
||||||
|
Bill = new List<CommandLine>
|
||||||
|
{
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
Normal file
256
src/Yavsc.Api.Test/FrontOfficeQueryAttachmentsApiTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the query (demande) attachment endpoints
|
||||||
|
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
|
||||||
|
/// which attach a file from the client's personal storage to their
|
||||||
|
/// <c>NominativeServiceCommand</c> **by reference**
|
||||||
|
/// (<see cref="QueryAttachedFile"/>).
|
||||||
|
///
|
||||||
|
/// <para>Coverage mirrors the controller contract: only the client
|
||||||
|
/// (the demande's <c>ClientId</c>) 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.</para>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Api")]
|
||||||
|
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Seed an Rdv query from bob (client) to alice (provider) and two
|
||||||
|
/// personal <see cref="UploadedFile"/> 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).
|
||||||
|
/// </summary>
|
||||||
|
private (long queryId, long bobFileId, long aliceFileId) Seed()
|
||||||
|
{
|
||||||
|
WorkflowHelpers.ConfigureBillingService();
|
||||||
|
_fixture.ResetAndSeedActivityGraph();
|
||||||
|
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
294
src/Yavsc.Blogs.Tests/FileSystemApiTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the personal-storage file endpoints
|
||||||
|
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
|
||||||
|
/// Blogs host.
|
||||||
|
///
|
||||||
|
/// <para>Two concerns:</para>
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item><description><b>Upload records metadata</b>: a multipart
|
||||||
|
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
|
||||||
|
/// owned by the caller and returns its <c>fileId</c>, so the client
|
||||||
|
/// can later attach that file to an estimate/query by
|
||||||
|
/// reference.</description></item>
|
||||||
|
/// <item><description><b>Download authorization</b>:
|
||||||
|
/// <c>GET api/v1/fs/file/{fileId}</c> 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.</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
[Collection("Yavsc Blogs")]
|
||||||
|
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Write a physical file into the owner's personal space,
|
||||||
|
/// so <c>GetFile</c> can stream it back. Creates the directory if
|
||||||
|
/// needed.</summary>
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<ApplicationDbContext>();
|
||||||
|
|
||||||
|
// 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<string>(),
|
||||||
|
AttachedGraphics = new List<string>(),
|
||||||
|
Bill = new List<CommandLine>
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue