From 8d2d6754f6ebaeacd311f5fd9b97e07516b17862 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 19:12:47 +0100 Subject: [PATCH 1/7] PostIt: negotiate HTTP/3 (QUIC) for Yavsc API calls Set DefaultRequestVersion = 3.0 and DefaultVersionPolicy = RequestVersionOrLower on the shared YavscApiClient.Http instance, so all Yavsc.Api.Client traffic prefers HTTP/3 against the nginx edge vhosts (listen 443 quic ; http3 on ; Alt-Svc) and falls back to h2/h1.1 where QUIC isn't available (WASM fetch, Android/desktop without libmsquic). Co-Authored-By: Claude Code --- src/PostIt/PostIt/Services/YavscApiClient.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 555a7c675..dcc618438 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -49,6 +49,16 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable _bearer = new BearerTokenHandler(this); Http = new HttpClient(_bearer, disposeHandler: true); + // HTTP/3 (QUIC) vers les APIs Yavsc : le edge nginx termine h3 sur + // tous les vhosts (listen 443 quic ; http3 on ; Alt-Svc h3=":443"). + // RequestVersionOrLower négocie h3 quand le serveur ET la plateforme + // le permettent (Desktop avec libmsquic), et retombe sur h2/h1.1 + // sinon — notamment WASM, où le navigateur n'expose pas QUIC au + // fetch, et Android si libmsquic n'est pas disponible. Tous les + // clients Yavsc.Api.Client partagent cette même instance `Http`. + Http.DefaultRequestVersion = new Version(3, 0); + Http.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + _tokens = store.Load(); } From d8c13c81d7cc99d203b571f64dbd61436eed90cf Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 20:42:54 +0100 Subject: [PATCH 2/7] Server: wire ProfileService so access tokens carry the name claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom ProfileService (Yavsc.Services.ProfileService) was never registered with IdentityServer, so IS8 fell back to its default profile service — which only emits claims listed in each API resource's UserClaims (RequestedClaimTypes). "name" was never among them, so every access token lacked "name" and GetUserName() resolved to null on all bearer resource hosts (Blogs/Api): the MyFiles upload 500 and the unreachable User.Identity.Name. The class was dead code; all prior tweaks to it (FullName→UserName) and to ServiceExtensions (MapInboundClaims=false) were necessary but insufficient until it was actually wired in. - HostingExtensions: AddProfileService() after AddAspNetIdentity(). - ProfileService: emit name=user.UserName, email, roles for any user grant whose client has "profile" in AllowedScopes, independent of the caller (the old ParsedScopes gate only fired for the id-token caller, never the access-token caller). - ServiceExtensions: JwtBearer NameClaimType=JwtClaimTypes.Name, MapInboundClaims=false (keep short claim names as-emitted). - FileSystemHelpers: EnsureDestinationDirectory uses GetUserName() (the "name" claim) instead of User.Identity.Name for the per-user storage root. Tests: - TestTokenIssuer: stamp a "name" claim = subject so test tokens mirror production shape (fixes FileSystemApiTests upload 500). - Remoting: PasswordToken_carries_name_claim_equal_to_login mints a real JWT via the IS token endpoint (password grant, openid profile) and asserts the access token carries name=login and GetUserName() resolves to it. - WebServerFixture: AccessTokenType=0 (Jwt in IS8; 1=Reference, the inverse of Duende); allow openid+profile on the test client; re-seed the openid/profile identity resources in ReseedAuthTestData (a ResetAndMigrateDatabase wipes the shared in-memory store and previously left them gone → invalid_scope). - BillingServiceTests: [Collection("Yavsc Server")] to serialize with the host build that mutates the static BillingService/Config registries (pre-existing parallel race that the new test's timing surfaced). Co-Authored-By: Claude Code --- src/Yavsc.Org.Tests/Mandatory/Remoting.cs | 69 +++++++++++++++++++ .../NonRegression/BillingServiceTests.cs | 10 +++ src/Yavsc.Org.Tests/WebServerFixture.cs | 45 +++++++++++- src/Yavsc.Org/Extensions/HostingExtensions.cs | 11 +++ src/Yavsc.Server/Helpers/FileSystemHelpers.cs | 10 ++- src/Yavsc.Server/Helpers/ServiceExtensions.cs | 19 ++++- src/Yavsc.Server/Services/ProfileService.cs | 39 ++++++----- src/Yavsc.Tests.Shared/TestTokenIssuer.cs | 8 +++ 8 files changed, 185 insertions(+), 26 deletions(-) diff --git a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs index 7def1a43f..5caf905b4 100644 --- a/src/Yavsc.Org.Tests/Mandatory/Remoting.cs +++ b/src/Yavsc.Org.Tests/Mandatory/Remoting.cs @@ -1,7 +1,10 @@ using System.Security.Cryptography.X509Certificates; using System.Net.Security; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; using IdentityModel.Client; using Xunit; +using Yavsc.Server.Helpers; namespace Yavsc.Org.Tests { @@ -70,6 +73,72 @@ namespace Yavsc.Org.Tests } + /// + /// Regression for the MyFiles upload 500 / null + /// User.Identity.Name: the access token minted by the + /// real IdentityServer (resource-owner password grant) must + /// carry a name claim whose value is the user's login + /// (UserName), and + /// — which reads FindFirstValue("name") — must resolve + /// to it on a principal built the way + /// AddYavscJwtBearer builds one + /// (MapInboundClaims=false, NameClaimType="name"). + /// + /// Prerequisites wired by : + /// the test client's AllowedScopes include + /// openid+profile, and the standard identity + /// resources are seeded by the production pipeline. Requesting + /// openid profile makes ProfileService emit + /// name = user.UserName. + /// + [Fact] + public async Task PasswordToken_carries_name_claim_equal_to_login() + { + var serverUrl = GetServerUrl(); + var cancellationToken = TestContext.Current.CancellationToken; + + var client = NewHttpClient(); + var tokenEndpoint = await ResolveTokenEndpointAsync(client, serverUrl, cancellationToken); + + var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest + { + Address = tokenEndpoint, + ClientId = RequireNonEmpty(_serverFixture.TestClientId, nameof(_serverFixture.TestClientId)), + ClientSecret = RequireNonEmpty(_serverFixture.TestClientSecret, nameof(_serverFixture.TestClientSecret)), + UserName = RequireNonEmpty(_serverFixture.TestingUserName, nameof(_serverFixture.TestingUserName)), + Password = RequireNonEmpty(_serverFixture.TestingUserPassword, nameof(_serverFixture.TestingUserPassword)), + // "openid profile" triggers ProfileService to emit the + // "name" claim (user.UserName). Without profile, the + // token carries only sub + scopes and GetUserName() + // resolves to null — the exact regression behind the + // MyFiles upload 500. "test" is the API scope, so a real + // JWT access token (not just an id_token) is issued. + Scope = "openid profile test" + }, cancellationToken); + + Assert.False(response.IsError, + $"Token request failed: {response.Error} ({response.ErrorDescription})"); + + // Decode the access token straight from the payload so short + // claim names ("name", "sub") stay as-emitted — mirrors + // AddYavscJwtBearer with MapInboundClaims=false. + var jwt = new JwtSecurityToken(response.AccessToken); + var nameClaim = jwt.Claims.FirstOrDefault(c => c.Type == "name"); + Assert.True(nameClaim is not null, + "access_token has no 'name' claim; ProfileService did not emit it " + + "(is the 'profile' scope in the client's AllowedScopes and requested?)."); + Assert.Equal(_serverFixture.TestingUserName, nameClaim!.Value); + + // Rebuild the principal the way a bearer consumer host does + // and exercise the real GetUserName() helper used by the + // file-system / avatar paths. + var identity = new ClaimsIdentity( + jwt.Claims, authenticationType: "Bearer", + nameType: "name", roleType: null); + var principal = new ClaimsPrincipal(identity); + Assert.Equal(_serverFixture.TestingUserName, principal.GetUserName()); + } + [Fact] public async Task GetSignin_returns_a_page() { diff --git a/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs b/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs index 4e8ae0d6f..6504be24d 100644 --- a/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs +++ b/src/Yavsc.Org.Tests/NonRegression/BillingServiceTests.cs @@ -8,6 +8,16 @@ using Xunit; namespace Yavsc { + // BillingService and Config expose static mutable registries that + // WorkflowHelpers.ConfigureBillingService() also mutates from the + // WebServerFixture host build (HostingExtensions.cs). Without a + // collection, xUnit runs this class in parallel with the + // "Yavsc Server" collection, and the host build's ConfigureBilling + // call can land between this test's two ConfigureBilling calls, + // breaking the idempotency count assertion (a flaky failure that + // surfaces whenever the fixture's timing shifts). Pinning the class + // to the same collection serializes it with the host build. + [Collection("Yavsc Server")] [Trait("regression", "II")] public class BillingServiceTests { diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs index 5ca1daa06..522103023 100644 --- a/src/Yavsc.Org.Tests/WebServerFixture.cs +++ b/src/Yavsc.Org.Tests/WebServerFixture.cs @@ -1,4 +1,5 @@ using IdentityServer8.EntityFramework.Entities; +using IdentityServer8.EntityFramework.Mappers; using IdentityServer8.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; @@ -238,6 +239,26 @@ public sealed class WebServerFixture : WebHostFixture AddAuthorizedClient(scope, TestClientId, TestClientSecret); } + // EnsureDefaultApplicationScopes seeds the standard identity + // resources (openid, profile) once at host startup, but a + // ResetAndMigrateDatabase call does EnsureDeleted on the shared + // in-memory store, which wipes them — and ReseedAuthTestData + // previously only re-added the client. Without openid/profile + // present, any token request asking for those scopes returns + // invalid_scope, so a password-grant token carrying the "name" + // claim (scope "openid profile") could not be minted after a + // reset. Re-seed them here to mirror the startup seed. + var identityResources = configDb.Set(); + if (!identityResources.Any(r => r.Name == "openid")) + { + identityResources.Add(new IdentityResources.OpenId().ToEntity()); + } + if (!identityResources.Any(r => r.Name == "profile")) + { + identityResources.Add(new IdentityResources.Profile().ToEntity()); + } + configDb.SaveChanges(); + _sharedTestClientId = TestClientId; _sharedTestClientSecret = TestClientSecret; _sharedTestingUserName = TestingUserName; @@ -284,7 +305,12 @@ public sealed class WebServerFixture : WebHostFixture { ClientId = testClientId, AccessTokenLifetime = 3600000, - AccessTokenType = 1, + // AccessTokenType = 0 → Jwt in IdentityServer8 (1 = Reference, + // the inverse of Duende's enum). The real PostIt client issues + // JWT access tokens so the Blogs/Api bearer hosts can validate + // them with JwtBearer; mirror that here so the token can be + // decoded and its "name" claim asserted. + AccessTokenType = 0, ClientName = "Testing client", Enabled = true, RequireClientSecret = true @@ -327,6 +353,23 @@ public sealed class WebServerFixture : WebHostFixture ClientId = testingClient.Id, Scope = "test" }); + // Allow the identity scopes so a resource-owner-password token + // can request "openid profile" and trigger ProfileService to + // emit the "name" claim (user.UserName). Without profile in the + // client's AllowedScopes, IdentityServer strips the scope from + // the request and the access_token carries no "name" — the + // regression that left GetUserName() / the MyFiles upload root + // null. Additive only: existing tests request "test" alone. + configDb.Set().Add(new ClientScope + { + ClientId = testingClient.Id, + Scope = "openid" + }); + configDb.Set().Add(new ClientScope + { + ClientId = testingClient.Id, + Scope = "profile" + }); configDb.SaveChanges(); } diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index ea035001c..b5bb65cc5 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -368,6 +368,17 @@ public static class HostingExtensions }) .AddAspNetIdentity() + // Register the custom ProfileService so IdentityServer actually + // calls it instead of the default UserProfileService. Without + // this, the class is dead code: IS8 falls back to the default, + // which only emits claims listed in each API resource's + // UserClaims (RequestedClaimTypes) — "name" is never among + // them, so every access token lacks "name" and the Blogs/Api + // bearer hosts get GetUserName() == null (the MyFiles upload + // 500). Our ProfileService emits name/email/roles for any + // user grant whose client is allowed "profile", independent + // of the caller, so the access token carries the login. + .AddProfileService() .AddClientStore() .AddClientConfigurationValidator() .AddCorsPolicyService() diff --git a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs index 77e691ea5..762c18f36 100644 --- a/src/Yavsc.Server/Helpers/FileSystemHelpers.cs +++ b/src/Yavsc.Server/Helpers/FileSystemHelpers.cs @@ -1,19 +1,17 @@ #nullable enable annotations - - - using System.Security.Claims; using Microsoft.AspNetCore.Html; using Microsoft.Extensions.FileProviders; +using Microsoft.AspNetCore.Http; +using ImageMagick; using Yavsc.Models; using Yavsc.Models.Streaming; using Yavsc.ViewModels; -using Microsoft.AspNetCore.Http; using Yavsc.Exceptions; using Yavsc.Abstract.Helpers; -using ImageMagick; using Yavsc.Server.Models.FileSystem; using Yavsc.ViewModels.UserFiles; + namespace Yavsc.Server.Helpers { public static class FileSystemHelpers @@ -46,7 +44,7 @@ namespace Yavsc.Server.Helpers this ClaimsPrincipal user, string subpath, SiteSettings siteSettings) { - var root = Path.Combine(siteSettings.Blog, user.Identity.Name); + var root = Path.Combine(siteSettings.Blog, user.GetUserName()); if (!string.IsNullOrWhiteSpace(subpath)) { if (!subpath.IsValidYavscPath()) diff --git a/src/Yavsc.Server/Helpers/ServiceExtensions.cs b/src/Yavsc.Server/Helpers/ServiceExtensions.cs index 297084c28..397eada85 100644 --- a/src/Yavsc.Server/Helpers/ServiceExtensions.cs +++ b/src/Yavsc.Server/Helpers/ServiceExtensions.cs @@ -1,5 +1,6 @@ #nullable enable annotations +using IdentityModel; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Extensions.Configuration; @@ -107,9 +108,23 @@ public static class ServiceExtensions ValidateAudience = true, ValidAudiences = audiences, RoleClaimType = Constants.RoleClaimType, - NameClaimType = Constants.NameClaimType, + // IdentityServer's ProfileService emits the name claim as + // JwtClaimTypes.Name ("name", short form). MapInboundClaims + // (below) is disabled, so it stays "name" on the principal — + // NameClaimType must therefore be "name" too, or + // User.Identity.Name / GetUserName() resolve to null. The + // previous Constants.NameClaimType (the Microsoft long URI) + // never matched the remapped xmlsoap URI either, so the name + // was unreachable on every bearer host regardless of scope. + NameClaimType = JwtClaimTypes.Name, }; - options.MapInboundClaims = true; + // Keep JWT claim names as-emitted ("sub", "name", …). The default + // inbound map rewrites "name" → xmlsoap name URI and "sub" → + // nameid URI, which neither Constants.NameClaimType (microsoft + // URI) nor GetUserName()'s FindFirstValue("name") could match. + // Roles are already emitted as Constants.RoleClaimType (a long + // URI), so disabling the map leaves IsInMsRole() working. + options.MapInboundClaims = false; options.ClaimsIssuer = authority; options.Audience = audiences[0]; diff --git a/src/Yavsc.Server/Services/ProfileService.cs b/src/Yavsc.Server/Services/ProfileService.cs index 3b7b5121a..4c915f5c9 100644 --- a/src/Yavsc.Server/Services/ProfileService.cs +++ b/src/Yavsc.Server/Services/ProfileService.cs @@ -28,35 +28,40 @@ namespace Yavsc.Services var claims = new List { new Claim(JwtClaimTypes.Subject,user.Id.ToString()), }; - List claimAdds = new List(); foreach (var scope in context.RequestedResources.ParsedScopes) { if (context.Client.AllowedScopes.Contains(scope.ParsedName)) { claims.Add(new Claim(JwtClaimTypes.Scope, scope.ParsedName)); - claimAdds.Add(scope.ParsedName); } } - if (claimAdds.Contains(JwtClaimTypes.Profile)) + // name / email / roles are gated on the client being granted + // the "profile" identity scope. They MUST land in the *access + // token*: the Blogs/Api bearer resource hosts read the login + // via GetUserName() (FindFirstValue("name")) and roles via + // IsInMsRole() from the access token. But "profile" is an + // identity scope, which only appears in the id-token caller's + // ParsedScopes — never in the access-token caller's — so the + // previous gate (claimAdds.Contains("profile")) left name, + // email and roles out of every access token, and GetUserName() + // resolved to null on every bearer host (the MyFiles upload + // 500). Gate on the client's AllowedScopes instead, which is + // caller-independent, so both the access token and the id + // token carry these claims for any user grant whose client is + // allowed "profile". + if (context.Client.AllowedScopes.Contains(JwtClaimTypes.Profile)) { - claimAdds.Remove("profile"); - claimAdds.Add(JwtClaimTypes.Name); - claimAdds.Add(JwtClaimTypes.Email); - claimAdds.Add(Constants.RoleClaimType); - } - - if (claimAdds.Contains(JwtClaimTypes.Name)) - claims.Add(new Claim(JwtClaimTypes.Name, user.FullName)); - - if (claimAdds.Contains(JwtClaimTypes.Email)) + // "name" carries the login (UserName), not the display + // FullName: GetUserName() / User.Identity.Name feed + // filesystem paths (user files root, avatar file name), + // and FullName can contain spaces. No bearer-host + // consumer displays this claim as a human name today. + claims.Add(new Claim(JwtClaimTypes.Name, user.UserName)); claims.Add(new Claim(JwtClaimTypes.Email, user.Email)); - - if (claimAdds.Contains(Constants.RoleClaimType)) - { var roles = await this._userManager.GetRolesAsync(user); - if (roles.Count()>0) + if (roles.Count() > 0) { claims.AddRange(roles.Select(r => new Claim(Constants.RoleClaimType, r))); } diff --git a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs index 18aa2e00c..0046fc346 100644 --- a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs +++ b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs @@ -74,6 +74,14 @@ public static class TestTokenIssuer var claims = new List { new("sub", subject), + // Mirror production: IdentityServer's ProfileService emits the + // "name" claim as the user's login (UserName) when the profile + // scope is granted, and the file-system helpers resolve the + // per-user storage root via GetUserName() (FindFirstValue("name")) + // — not via User.Identity.Name. A test token without "name" would + // make ReceiveUserFile / EnsureDestinationDirectory compute a + // null owner root and 500 on upload, so stamp it here. + new("name", subject), new("scope", scope), }; if (extraClaims is not null) claims.AddRange(extraClaims); From a3078853109d1719e08033940f470da2e76e73d9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 20:50:58 +0100 Subject: [PATCH 3/7] Blogs: list personal files under the user's login, not the sub claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET api/v1/fs passed User.GetUserId() (the "sub" claim — a GUID in production) to GetUserFiles, so the listing looked under {Blog}/{sub-GUID}. But the personal-storage tree is keyed by the user's login everywhere else: EnsureDestinationDirectory writes under {Blog}/{name-claim}, the download endpoint and avatar paths use user.UserName. So once the upload path was fixed (previous commit), files landed under {Blog}/{login} while the listing still looked under {Blog}/{GUID} — an empty tree — hence the PostIt "Mes fichiers" page listing nothing. GetDir now resolves the user from the DB (as Post already does) and lists under user.UserName. UploadedFile.OwnerId stays "sub", so EnrichWithFileIds still queries by uid. Tests: - TestTokenIssuer.Issue gains an optional `name` param (defaults to subject, preserving existing behavior) so a test token can mirror production where sub (GUID) ≠ name (login). - FileSystemApiTests.Get_fs_lists_files_stored_under_the_user_login decouples sub from the login, uploads under the login, and asserts GET /api/v1/fs returns the file (and the subject-named directory does NOT exist). Verified to fail when the controller uses uid for the listing. Co-Authored-By: Claude Code --- src/Yavsc.Blogs.Tests/FileSystemApiTests.cs | 76 ++++++++++++++++++- .../Controllers/FileSystemApiController.cs | 13 +++- src/Yavsc.Tests.Shared/TestTokenIssuer.cs | 9 ++- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs index b5eeb8e00..e67f235aa 100644 --- a/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs +++ b/src/Yavsc.Blogs.Tests/FileSystemApiTests.cs @@ -44,7 +44,7 @@ public sealed class FileSystemApiTests : IClassFixture _fixture = fixture; } - private HttpClient NewClient(string subject = "alice") + private HttpClient NewClient(string subject = "alice", string? name = null) { var handler = new HttpClientHandler { @@ -54,8 +54,11 @@ public sealed class FileSystemApiTests : IClassFixture { BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) }; + // Pass a distinct name to mirror production (sub = GUID, name = + // login); defaults to subject for the existing tests where the + // two are equal. http.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject)); + new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, name: name)); return http; } @@ -129,6 +132,75 @@ public sealed class FileSystemApiTests : IClassFixture "Expected the uploaded file on disk under the owner's personal root"); } + /// + /// Regression for the "Mes fichiers" page listing nothing: the + /// personal-storage tree is keyed by the user's login + /// (UserName) — EnsureDestinationDirectory writes + /// under {Blog}/{name-claim}, the download endpoint and + /// avatar paths use user.UserName — but GET api/v1/fs + /// passed User.GetUserId() (the sub claim, a GUID in + /// production) to GetUserFiles, so the listing looked under + /// {Blog}/{sub-GUID}, an empty tree. This test decouples + /// sub from the login (mirroring production) and asserts the + /// listing returns the file stored under the login. + /// + [Fact] + public async Task Get_fs_lists_files_stored_under_the_user_login() + { + _fixture.ResetDatabase(); + // sub = "alice-sub" (GUID-like), login = "alice" — the shape + // ProfileService mints in production. DiskQuota non-zero so the + // upload persists the UploadedFile row (see Post_fs test). + _fixture.SeedUser("alice", u => + { + u.Id = "alice-sub"; + u.DiskQuota = 10_000_000; + }); + + var payload = System.Text.Encoding.UTF8.GetBytes("list me"); + + using var form = new MultipartFormDataContent(); + var fileContent = new ByteArrayContent(payload); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"); + form.Add(fileContent, "file", "note.txt"); + + // Upload with a token whose sub is the GUID-like id and whose + // name is the login — EnsureDestinationDirectory keys off "name". + using (var http = NewClient("alice-sub", name: "alice")) + { + var post = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken); + var postBody = await post.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(post.StatusCode == HttpStatusCode.OK, + $"Upload failed: {(int)post.StatusCode} ({post.StatusCode}): {postBody}"); + } + + // The file landed under the login, not the subject. + Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")), + "Expected the uploaded file under {BlogFilesRoot}/alice/note.txt (the login)"); + Assert.False(Directory.Exists(Path.Combine(_fixture.BlogFilesRoot, "alice-sub")), + "The subject-named directory must NOT exist — storage is keyed by the login"); + + // GET /api/v1/fs must list under the login and find the file. + using var get = NewClient("alice-sub", name: "alice"); + var response = await get.GetAsync("/api/v1/fs", TestContext.Current.CancellationToken); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + Assert.True(response.StatusCode == HttpStatusCode.OK, + $"List failed: {(int)response.StatusCode} ({response.StatusCode}): {body}"); + + using var doc = JsonDocument.Parse(body); + Assert.True(doc.RootElement.TryGetProperty("files", out var filesEl), + $"Response has no 'files' property. Body: {body}"); + var names = filesEl.EnumerateArray().Select(f => f.GetProperty("name").GetString()).ToArray(); + Assert.Contains("note.txt", names); + + // The UploadedFile row (OwnerId = sub) is enriched onto the + // entry, so the client can attach the file by reference. + var noteEntry = filesEl.EnumerateArray() + .First(f => f.GetProperty("name").GetString() == "note.txt"); + Assert.True(noteEntry.TryGetProperty("id", out var idEl) && idEl.GetInt64() > 0, + "Expected the enriched UploadedFile id on the listed entry"); + } + [Fact] public async Task GetFile_owner_can_download_their_own_file() { diff --git a/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs b/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs index 1b1038f57..2ca4250c0 100644 --- a/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs +++ b/src/Yavsc.Blogs/Controllers/FileSystemApiController.cs @@ -54,7 +54,18 @@ namespace Yavsc.Blogs.Controllers { if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState); var uid = User.GetUserId(); - var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir); + // The personal-storage tree is keyed by the user's login + // (UserName): EnsureDestinationDirectory writes under + // {Blog}/{UserName}, the download endpoint and avatar paths + // use user.UserName too. GetUserId() returns the "sub" claim + // (a GUID in production), so passing uid here made the listing + // look under {Blog}/{sub-GUID} — an empty tree, hence the + // "Mes fichiers" page listing nothing. Resolve the login and + // list under it. OwnerId on UploadedFile stays "sub" (uid), + // so EnrichWithFileIds still queries by uid. + var user = dbContext.Users.SingleOrDefault(u => u.Id == uid); + if (user == null) return NotFound(); + var files = FileSystemHelpers.GetUserFiles(siteSettings, user.UserName, subdir); EnrichWithFileIds(files, uid, subdir); return Ok(files); } diff --git a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs index 0046fc346..b1e0fc005 100644 --- a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs +++ b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs @@ -63,11 +63,18 @@ public static class TestTokenIssuer /// Value of the scope claim. The /// production BlogScope policy requires /// RequireClaim("scope", "blogs"). + /// Value of the name claim. Defaults to + /// (the test convention where the login + /// equals the subject). Pass a distinct login to mirror production, + /// where sub is a GUID and name is the user's + /// UserName — the file-system list/upload paths key the + /// personal-storage tree off the login, not the subject. /// Optional additional claims /// (e.g. a role for an admin-bypass test). public static string Issue( string subject, string scope = "blogs", + string? name = null, IEnumerable? extraClaims = null) { var now = DateTime.UtcNow; @@ -81,7 +88,7 @@ public static class TestTokenIssuer // — not via User.Identity.Name. A test token without "name" would // make ReceiveUserFile / EnsureDestinationDirectory compute a // null owner root and 500 on upload, so stamp it here. - new("name", subject), + new("name", name ?? subject), new("scope", scope), }; if (extraClaims is not null) claims.AddRange(extraClaims); From 4554886e332eb5ddd7096bda6406d9f29393cb99 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 25 Sep 2026 21:17:33 +0100 Subject: [PATCH 4/7] PostIt: fix grayed/inert file-row action buttons (wrong $parent binding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action buttons inside ItemsControl item templates (Télécharger / Supprimer / Joindre on MyFiles; download/detach on the attachment lists of BillingQueryDetailsPage and EstimateEditionPage) were bound with $parent[vm:XxxViewModel].Cmd, which searches for an ancestor CONTROL of type XxxViewModel — a viewmodel, not a control — so the binding resolved to null and the Button stayed disabled (grayed) and inert on click. Switch to the working CirclesPage/EstimateListPage syntax: $parent[ContentPage].((vm:XxxViewModel)DataContext).Cmd. Adds MyFilesPageHeadlessTests: mounts MyFilesPage via NavigationPage + Window.Show() and asserts each row button's Command is bound, armed (CanExecute), and fires the API call on click. 136/136 pass. Co-Authored-By: Claude Code --- .../PostIt.Tests/MyFilesPageHeadlessTests.cs | 208 ++++++++++++++++++ .../Activity/BillingQueryDetailsPage.axaml | 6 +- .../Views/Activity/EstimateEditionPage.axaml | 4 +- .../PostIt/Views/MyFiles/MyFilesPage.axaml | 10 +- 4 files changed, 218 insertions(+), 10 deletions(-) create mode 100644 src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs diff --git a/src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs b/src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs new file mode 100644 index 000000000..72619f3c5 --- /dev/null +++ b/src/PostIt/PostIt.Tests/MyFilesPageHeadlessTests.cs @@ -0,0 +1,208 @@ +using System.Net.Http; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.VisualTree; +using CommunityToolkit.Mvvm.Input; +using PostIt.ViewModels; +using PostIt.Views; +using Yavsc.Api.Client; + +namespace PostIt.Tests; + +/// +/// Headless UI coverage for the "Mes fichiers" page +/// (). The user reported that once a file +/// is listed, "l'item est inerte au click, et ses boutons restent +/// grisés, désactivés" — neither the row nor its action buttons +/// (Télécharger / Supprimer) respond to a click. +/// +/// These tests mount a real under +/// the Avalonia headless platform with a stub +/// that lists one file, then walk the +/// realised visual tree to find the row's action buttons and +/// assert the two things that would silently break and reproduce +/// the "grayed / inert" symptom: +/// +/// The Command binding +/// ($parent[vm:MyFilesViewModel].DownloadFileCommand) +/// resolved to a non-null command — a null command leaves the +/// button disabled (grayed) and click-silent. +/// The command is armed +/// (CanExecute true) for the listed file — an +/// AsyncRelayCommand with +/// AllowConcurrentExecutions=false (the +/// +/// default) disables itself while running; a button stuck +/// "running" is exactly the grayed-and-inert report. +/// Executing the command actually invokes the +/// file download / delete on the API client — the row +/// "activates" on click instead of being inert. +/// +/// The OS save picker is absent under headless, so +/// FileSaveHelpers.SaveAsync throws or cancels and the +/// download command's catch sets an error status — but the +/// API download is awaited before the picker, so the call +/// is recorded regardless. We do not await the command's +/// ExecutionTask to completion, to avoid blocking on the +/// (platform-dependent) picker. +/// +public class MyFilesPageHeadlessTests +{ + private static (MyFilesPage page, MyFilesViewModel vm, StubFsApi api) CreatePage() + { + var api = new StubFsApi(); + var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); + var vm = new MyFilesViewModel(fsClient); + var page = new MyFilesPage { DataContext = vm }; + return (page, vm, api); + } + + /// + /// Mount the page the way the app does — pushed onto a + /// shown in a + /// (a realizes its content template via + /// the navigation host, cf. RdvPageHeadlessTests) — after + /// listing a single file by awaiting + /// . Populating + /// before the first layout + /// pass lets the ItemsControl realise its item templates + /// under headless. + /// + private static async Task<(MyFilesPage page, MyFilesViewModel vm, StubFsApi api)> MountAsync() + { + var (page, vm, api) = CreatePage(); + await vm.InitializeAsync(); + var nav = new NavigationPage(); + nav.PushAsync(page).GetAwaiter().GetResult(); + var window = new Window { Content = nav }; + window.Show(); + // Let bindings / the ItemsControl item templates apply. + await Task.Delay(50); + return (page, vm, api); + } + + /// + /// Find the first action button in a file row whose content + /// matches . The file rows live in an + /// ItemsControl DataTemplate (no x:Name), so we + /// walk the visual tree. + /// + private static Button? FindRowButton(MyFilesPage page, string content) + => page.GetVisualDescendants() + .OfType