From 8c38bab45abcc8568a23cef9adc2af5fcee0953d Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 6 Jul 2026 21:49:53 +0100 Subject: [PATCH 01/27] feat(tests): scaffold Yavsc.Blogs.Tests with BlogsWebServerFixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the test project that the next commit will use to assert the blog API endpoints. The fixture inherits from the shared WebHostFixture (commit "refactor: extract WebHostFixture…") and wires up only the bits the blog API needs: * In-memory ApplicationDbContext — BlogSpotService is used as-is, no mock. The first tests will exercise the real service against an empty table. * Trivial IFileSystemAuthManager stub (the GET index path never reads the file system). * TestAuthPolicyProvider swapped in, so X-Test-Role satisfies [Authorize("BlogScope")]. Two smoke tests verify the fixture boots and the controller pipeline is reachable. The first behavioural test (GET /api/v1/blog → 200) lands in the next commit. Also promotes two xunit.v3.* package versions to the root Directory.Packages.props so future test projects can share them. --- Directory.Packages.props | 2 + src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs | 42 +++++++ .../BlogsWebServerFixture.cs | 104 ++++++++++++++++++ .../Directory.Packages.props | 7 ++ .../Yavsc.Blogs.Tests.csproj | 37 +++++++ .../Yavsc.Tests.Shared.csproj | 3 +- yavsc.sln | 30 +++++ 7 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs create mode 100644 src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs create mode 100644 src/Yavsc.Blogs.Tests/Directory.Packages.props create mode 100644 src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index e7f71232..ff3bd4bf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,8 @@ + + \ No newline at end of file diff --git a/src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs b/src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs new file mode 100644 index 00000000..b84e75b6 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogApiSmokeTests.cs @@ -0,0 +1,42 @@ +using System.Net; +using System.Net.Http; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +/// +/// Smoke tests for the Yavsc.Blogs API host. These tests only assert +/// that the fixture boots and the test HTTP client reaches the +/// controller pipeline — they do not yet exercise the controller +/// surface. The first behavioural test (GET /api/v1/blog returns +/// 200) lands in a follow-up commit. +/// +public sealed class BlogApiSmokeTests : IClassFixture +{ + private readonly BlogsWebServerFixture _fixture; + + public BlogApiSmokeTests(BlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public void Fixture_Binds_At_Least_One_Https_Address() + { + Assert.NotEmpty(_fixture.Addresses); + Assert.Contains(_fixture.Addresses, a => a.StartsWith("https://")); + } + + [Fact] + public void Fixture_Exposes_Resolving_ServiceProvider() + { + // If the host built correctly, the service provider should + // be available and resolvable. We don't need to assert a + // specific service here — the GET 200 test will exercise + // the BlogSpotService indirectly. + Assert.NotNull(_fixture.Services); + using var scope = _fixture.Services.CreateScope(); + Assert.NotNull(scope.ServiceProvider); + } +} diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs new file mode 100644 index 00000000..608c4fdc --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -0,0 +1,104 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Yavsc; +using Yavsc.Models; +using Yavsc.Server.Services; +using Yavsc.Services; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +/// +/// Test host for the Yavsc.Blogs API surface. Specialisation of +/// that wires up only the bits the +/// blog API actually depends on: +/// +/// +/// An in-memory +/// (the real one — no mock) so BlogSpotService.Index can run +/// against an empty table and return an empty list. +/// A trivial +/// stub: the GET index path doesn't read the file system, so any +/// implementation is fine. +/// The default +/// from Microsoft.AspNetCore.Authorization. +/// The test auth bypass from +/// Yavsc.Tests.Shared so the [Authorize("BlogScope")] +/// attribute on BlogApiController is satisfied when the +/// test sends the X-Test-Role header. +/// +/// +/// No IdentityServer, no SMTP, no static assets — the Org fixture +/// owns all of that and we don't need any of it for blog integration +/// tests. +/// +public sealed class BlogsWebServerFixture : WebHostFixture +{ + protected override WebApplication BuildApp(WebApplicationBuilder builder) + { + // Use the real ApplicationDbContext with an in-memory store. + // BlogSpotService reads _context.BlogSpot directly, so any + // attempt to mock it would be wasted work; the real service + // against an empty table returns an empty list, which is + // exactly what the first test wants to assert. + builder.Services.AddDbContext(opt => + opt.UseInMemoryDatabase("Yavsc.Blogs.Tests")); + + // Trivial file-system auth: the GET index path never calls + // into it, but the DI container needs an instance. + builder.Services.AddSingleton( + new NoopFileSystemAuthManager()); + + // Real BlogSpotService — same instance the production host + // builds (ApplicationDbContext, IAuthorizationService, + // IFileSystemAuthManager). + builder.Services.AddScoped(); + + // The BlogApiController is reached through MVC, so register + // MVC + the BlogScope authorization policy. + builder.Services.AddControllers(); + builder.Services.AddAuthorization(opt => + { + // Mirror the production "BlogScope" policy: any + // authenticated user. The TestAuthPolicyProvider we + // register below short-circuits the role check via the + // X-Test-Role header. + opt.AddPolicy("BlogScope", p => p.RequireAssertion(_ => true)); + }); + + // Test auth bypass — swapped in BEFORE the host builds the + // service collection, so it overrides any production + // policy provider registered by AddAuthorization above. + builder.Services.AddSingleton(); + + return builder.Build(); + } + + protected override async Task ConfigurePipelineAsync(WebApplication app) + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.MapControllers(); + await Task.CompletedTask; + return app; + } + + /// Trivial stub. The + /// blog API endpoints exercised by the first tests don't read the + /// file system, so the implementation can be a no-op. + private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager + { + public FileAccessRight GetFilePathAccess(ClaimsPrincipal user, string fileRelativePath) + => FileAccessRight.None; + + public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) + { + } + } +} diff --git a/src/Yavsc.Blogs.Tests/Directory.Packages.props b/src/Yavsc.Blogs.Tests/Directory.Packages.props new file mode 100644 index 00000000..eea5d34f --- /dev/null +++ b/src/Yavsc.Blogs.Tests/Directory.Packages.props @@ -0,0 +1,7 @@ + + + + diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj new file mode 100644 index 00000000..ec1f7f0a --- /dev/null +++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj @@ -0,0 +1,37 @@ + + + net10.0 + enable + enable + false + Yavsc.Blogs.Tests + b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82 + true + 1.0.1.0 + 1.0.1.0 + 1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f + 1.0.1-5 + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index b78ce03e..f9dc0876 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -13,9 +13,8 @@ inherit from the shared base classes. --> - - + \ No newline at end of file diff --git a/yavsc.sln b/yavsc.sln index 377146a6..fafdff89 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostI EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Blogs.Tests", "src\Yavsc.Blogs.Tests\Yavsc.Blogs.Tests.csproj", "{0E471075-DABF-40E9-98B7-1630BEF19145}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Tests.Shared", "src\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj", "{34D1F73D-BF74-47CC-9358-9F4F221C75D7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -187,6 +191,30 @@ Global {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x64.Build.0 = Release|Any CPU {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = Release|Any CPU {4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.Build.0 = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.ActiveCfg = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x64.Build.0 = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x86.ActiveCfg = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Debug|x86.Build.0 = Debug|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|Any CPU.Build.0 = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x64.ActiveCfg = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x64.Build.0 = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x86.ActiveCfg = Release|Any CPU + {0E471075-DABF-40E9-98B7-1630BEF19145}.Release|x86.Build.0 = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x64.ActiveCfg = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x64.Build.0 = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x86.ActiveCfg = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Debug|x86.Build.0 = Debug|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|Any CPU.Build.0 = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x64.ActiveCfg = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x64.Build.0 = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.ActiveCfg = Release|Any CPU + {34D1F73D-BF74-47CC-9358-9F4F221C75D7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -205,5 +233,7 @@ Global {AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE} {EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {E13D107F-4053-D0DE-6394-453609595BFE} {4D283324-6DD3-4CD1-9893-8C317772C6B5} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} + {0E471075-DABF-40E9-98B7-1630BEF19145} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} + {34D1F73D-BF74-47CC-9358-9F4F221C75D7} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} EndGlobalSection EndGlobal From 9f5a1505e370e79d5c7b882c3fbb54bb3ee2aaf9 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 6 Jul 2026 21:58:14 +0100 Subject: [PATCH 02/27] test(blogs): GET /api/v1/blog returns 200 with empty list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First behavioural test for the Yavsc.Blogs API surface. Sends a GET on the blog index with the X-Test-Role auth bypass and asserts the response is 200 with an empty JSON array — the in-memory ApplicationDbContext has no rows, and BlogSpotService.Index returns an empty enumeration. While here, fix a routing miss: AddControllers() in the test fixture was only scanning the test assembly, so BlogApiController was never registered. Add the Yavsc.Blogs application part explicitly. Without this, every request to /api/v1/blog came back as 404 — the same symptom PostIt was seeing in production. The POST flow lands in the next commit, once BlogApiController is made to accept JSON (it currently requires multipart/form-data because of Request.Form.Files). --- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 65 +++++++++++++++++++ .../BlogsWebServerFixture.cs | 14 ++-- 2 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/Yavsc.Blogs.Tests/BlogApiTests.cs diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs new file mode 100644 index 00000000..fcb5099a --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using System.Text.Json; + +namespace Yavsc.Blogs.Tests; + +/// +/// Behavioural tests for BlogApiController. Built on the +/// scaffold: in-memory +/// ApplicationDbContext, real BlogSpotService, +/// X-Test-Role for the [Authorize("BlogScope")] +/// attribute. +/// +public sealed class BlogApiTests : IClassFixture +{ + private readonly BlogsWebServerFixture _fixture; + + public BlogApiTests(BlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + /// The fixture's WebApplication is bound to + /// https://localhost:<random> via + /// . We pick the first + /// https URL and append the controller route + /// (/api/v1/blog, matching the production + /// [Route(APIPrefix + "/blog")]). + private string BlogsUrl => + _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; + + private HttpClient NewClient() + { + // The fixture's self-signed certificate is not in the user's + // trust store, so we accept anything (same pattern as + // Yavsc.Org.Tests' BypassSslValidationHandler). + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + var http = new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) + }; + http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole); + return http; + } + + [Fact] + public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() + { + using var http = NewClient(); + + var response = await http.GetAsync("/api/v1/blog"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var body = await response.Content.ReadAsStringAsync(); + // Empty table → empty JSON array. We compare as a JsonDocument + // so a future change in formatting (whitespace, indentation) + // doesn't break the assertion. + using var doc = JsonDocument.Parse(body); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } +} diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 608c4fdc..e40d0740 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -1,13 +1,10 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.FileProviders; -using Yavsc; +using Yavsc.Blogs.Controllers; using Yavsc.Models; -using Yavsc.Server.Services; using Yavsc.Services; using Yavsc.Tests.Shared; @@ -59,9 +56,12 @@ public sealed class BlogsWebServerFixture : WebHostFixture // IFileSystemAuthManager). builder.Services.AddScoped(); - // The BlogApiController is reached through MVC, so register - // MVC + the BlogScope authorization policy. - builder.Services.AddControllers(); + // The BlogApiController is reached through MVC. AddControllers() + // by default scans the test assembly only; we explicitly add the + // Yavsc.Blogs application part so the controller is discovered + // and routed. + builder.Services.AddControllers() + .AddApplicationPart(typeof(BlogApiController).Assembly); builder.Services.AddAuthorization(opt => { // Mirror the production "BlogScope" policy: any From 6d222cf819ad014c4f737a5b6bc8bdd8710b0a04 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 6 Jul 2026 22:03:41 +0100 Subject: [PATCH 03/27] fix(blogs): accept JSON on POST /api/v1/blog (no file) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlogApiController.PostBlog called Request.Form.Files unconditionally, which throws on a plain JSON body — the exception is "This request does not have a Content-Type header. Forms are available from requests with bodies like POSTs and a form Content-Type of either application/x-www-form-urlencoded or multipart/form-data." That broke PostIt's first-bill-of-blog flow: the client posts a BlogPost as JSON and has no files to attach. The endpoint contract is [FromBody] BlogPost, so the JSON body is deserialised into 'blog' as expected — only the IFormFileCollection argument to BlogSpotService.Create needs a real-or-empty value. Branch on Request.HasFormContentType: pass the form files when present, pass an empty FormFileCollection otherwise. BlogSpotService already short-circuits on a null/empty file collection, so the JSON-only path is now a clean code path. Tests: add PostBlog_creates_a_post_and_Get_returns_it_in_the_list (POST a draft, assert 201 + server-assigned Id, GET the index, assert exactly one entry with that Id). Add a per-test ResetDatabase helper because the in-memory store is shared across the lifetime of the BlogsWebServerFixture instance. --- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 58 +++++++++++++++++++ .../Controllers/BlogApiController.cs | 24 +++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index fcb5099a..b2ab171a 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -1,5 +1,11 @@ using System.Net; +using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Yavsc.Models; +using Yavsc.Models.Blog; +using Yavsc.Tests.Shared; namespace Yavsc.Blogs.Tests; @@ -19,6 +25,19 @@ public sealed class BlogApiTests : IClassFixture _fixture = fixture; } + /// Reset the in-memory database to a known empty state. + /// UseInMemoryDatabase shares its store across the + /// lifetime of the instance, + /// so without a per-test reset the test order would leak + /// state between tests. + private void ResetDatabase() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + /// The fixture's WebApplication is bound to /// https://localhost:<random> via /// . We pick the first @@ -48,6 +67,7 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { + ResetDatabase(); using var http = NewClient(); var response = await http.GetAsync("/api/v1/blog"); @@ -62,4 +82,42 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); Assert.Equal(0, doc.RootElement.GetArrayLength()); } + + [Fact] + public async Task PostBlog_creates_a_post_and_Get_returns_it_in_the_list() + { + ResetDatabase(); + using var http = NewClient(); + + // Create a minimal BlogPost. The server assigns Id, so we + // send 0 + an explicit AuthorId; the production + // BlogSpotService.Create() tolerates that. + var draft = new BlogPost + { + Id = 0, + Title = "Premier billet", + AuthorId = "tester", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + // The POST returns the server-issued post (with a real Id). + var created = await postResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.NotEqual(0, created!.Id); + Assert.Equal(draft.Title, created.Title); + + // The list should now contain exactly one entry. + var listResponse = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(1, doc.RootElement.GetArrayLength()); + Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); + } } diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 89f74ad2..31afa163 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -92,7 +92,29 @@ namespace Yavsc.Blogs.Controllers return BadRequest(ModelState); } - var post = blogSpotService.Create(User.GetUserId(), blog, Request.Form.Files); + // The BlogSpotService.Create() signature requires an + // IFormFileCollection for file uploads. Reading + // Request.Form.Files when the request is a plain JSON + // body (e.g. from PostIt) throws + // "This request does not have a Content-Type header. + // Forms are available from requests with bodies like + // POSTs and a form Content-Type of either + // application/x-www-form-urlencoded or + // multipart/form-data." + // + // Two valid use cases for this endpoint: + // 1. JSON body only (no files) — PostIt path. + // 2. multipart/form-data with a 'blog' field + 0..N + // files — future browser / server-rendered path. + // + // Branch on HasFormContentType: pass the form files when + // present, pass an empty collection otherwise. The + // FileSystem branch in BlogSpotService.Create then + // short-circuits to "no files to handle". + var files = Request.HasFormContentType + ? Request.Form.Files + : (IFormFileCollection)new FormFileCollection(); + var post = blogSpotService.Create(User.GetUserId(), blog, files); return CreatedAtRoute("GetBlog", new { id = post.Id }, post); } From c3f2408c4a924120d811d5e7634e5a8e8b1f6c28 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 6 Jul 2026 23:29:51 +0100 Subject: [PATCH 04/27] test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the Blogs integration test host with a real AddJwtBearer (HS256, IssuerSigningKey shared with the new TestTokenIssuer) and the production BlogScope policy verbatim, instead of the TestAuthPolicyProvider / AllowAllAuthorizationService / NoopAuthHandler stack that short-circuited every authorization check. Why: BlogSpotService.Modify calls IAuthorizationService.AuthorizeAsync(user, blog, EditPermission); the previous AllowAllAuthorizationService stub made that a no-op, so the tests could not exercise the real ownership chain and any change in PermissionHandler would silently slip through. The new test host registers the real PermissionHandler, so a PUT that succeeds (204) is now proof that PermissionHandler.IsOwner accepted the request — i.e. the JWT's sub matched the post's AuthorId, end-to-end. Notes for future-me: - JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is called once on the first Issue() to keep the 'sub' claim literal; without it UserHelpers.GetUserId (which reads 'sub') gets ClaimTypes.NameIdentifier instead, returns null, and the owner check fails for every PUT. The companion options.MapInboundClaims = false on the validation pipeline keeps both sides in sync. - Production still uses AddYavscJwtBearer against the OIDC authority; the test-only HS256 path is local to the test process and never crosses a network boundary. Coverage: - GetBlog_returns_401_when_no_token_is_provided — anonymous request, real policy fails closed. - PutBlog_with_valid_token_and_owner_returns_204_and_Get_ reflects_update — POST then PUT then GET, all behind a real JWT, asserting 204 + list contains the updated title. Packages added to Directory.Packages.props at 8.2.1 to match what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already transitively pulls in (no version drift). --- Directory.Packages.props | 2 + src/Yavsc.Blogs.Tests/BlogApiTests.cs | 132 +++++++++++++++++- .../BlogsWebServerFixture.cs | 109 ++++++++++++--- src/Yavsc.Tests.Shared/TestTokenIssuer.cs | 107 ++++++++++++++ .../Yavsc.Tests.Shared.csproj | 2 + 5 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 src/Yavsc.Tests.Shared/TestTokenIssuer.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ff3bd4bf..e4b09159 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index b2ab171a..658f5107 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -12,9 +12,13 @@ namespace Yavsc.Blogs.Tests; /// /// Behavioural tests for BlogApiController. Built on the /// scaffold: in-memory -/// ApplicationDbContext, real BlogSpotService, -/// X-Test-Role for the [Authorize("BlogScope")] -/// attribute. +/// ApplicationDbContext, real BlogSpotService, and a +/// real AddJwtBearer validating HS256 tokens signed by +/// . The production BlogScope +/// policy runs unmodified — sending Authorization: Bearer … +/// with a valid token is what gets a request through, omitting the +/// header (or sending a token signed with the wrong key) gets a +/// 401 back from the framework. /// public sealed class BlogApiTests : IClassFixture { @@ -47,7 +51,13 @@ public sealed class BlogApiTests : IClassFixture private string BlogsUrl => _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; - private HttpClient NewClient() + /// Build an authenticated client: a real + /// Authorization: Bearer <jwt> header where the JWT + /// is signed by and carries + /// sub = subject. The production BlogScope policy + /// reads scope=blogs off the same token, so + /// TestTokenIssuer.Issue's default scope is enough. + private HttpClient NewClient(string subject = "tester") { // The fixture's self-signed certificate is not in the user's // trust store, so we accept anything (same pattern as @@ -60,10 +70,27 @@ public sealed class BlogApiTests : IClassFixture { BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) }; - http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole); + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", TestTokenIssuer.Issue(subject)); return http; } + /// Build an unauthenticated client. Used to assert that + /// the BlogScope policy fails closed when no bearer + /// token is presented. + private HttpClient NewAnonymousClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + return new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) + }; + } + [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { @@ -120,4 +147,99 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(1, doc.RootElement.GetArrayLength()); Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); } + + [Fact] + public async Task GetBlog_returns_401_when_no_token_is_provided() + { + ResetDatabase(); + using var http = NewAnonymousClient(); + + // No Authorization header → the JwtBearer middleware + // produces an unauthenticated principal, the BlogScope + // policy's RequireAuthenticatedUser requirement fails, and + // the framework returns 401. This is the proof that the + // production policy is wired in the test host and not + // short-circuited by a test-only auth bypass. + var response = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update() + { + ResetDatabase(); + // The JWT's sub must match the post's AuthorId: + // PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(), + // and UserHelpers.GetUserId reads "sub" off the principal. + // A mismatched sub → AuthorizationFailureException → + // Challenge() (401) from the controller. The 204 in this + // test is the proof that the real authorization chain + // accepted the request, end-to-end. + using var http = NewClient(subject: "tester"); + + // Seed a post we can update. + var draft = new BlogPost + { + Id = 0, + Title = "Avant", + AuthorId = "tester", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var created = (await postResponse.Content.ReadFromJsonAsync())!; + + // PUT with the server-issued Id; the controller rejects + // mismatched id/blog.Id with 400, so we keep them aligned. + var update = new BlogPost + { + Id = created.Id, + Title = "Après", + AuthorId = created.AuthorId, + Article = created.Article, + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }; + var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}", update); + Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); + + // The list should now reflect the new title. + var listResponse = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(1, doc.RootElement.GetArrayLength()); + Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString()); + } + + [Fact] + public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() + { + ResetDatabase(); + using var http = NewClient(); + + // Seed a post we can delete. + var draft = new BlogPost + { + Id = 0, + Title = "À supprimer", + AuthorId = "tester", + Article = "Contenu.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + var created = (await postResponse.Content.ReadFromJsonAsync())!; + + var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // The list should now be empty. + var listResponse = await http.GetAsync("/api/v1/blog"); + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } } diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index e40d0740..6e39fd00 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -1,8 +1,11 @@ -using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; using Yavsc.Blogs.Controllers; using Yavsc.Models; using Yavsc.Services; @@ -22,12 +25,21 @@ namespace Yavsc.Blogs.Tests; /// A trivial /// stub: the GET index path doesn't read the file system, so any /// implementation is fine. -/// The default -/// from Microsoft.AspNetCore.Authorization. -/// The test auth bypass from -/// Yavsc.Tests.Shared so the [Authorize("BlogScope")] -/// attribute on BlogApiController is satisfied when the -/// test sends the X-Test-Role header. +/// The real BlogSpotService, which calls +/// IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission()) +/// on PUT. The fixture registers the real +/// so the resource-based ownership +/// check runs end-to-end; tests that want a 204 PUT must sign a +/// JWT whose sub matches the post's AuthorId. +/// A real AddJwtBearer with HS256, +/// sharing its with the +/// token issuer. The production OIDC discovery path is bypassed: +/// the test host validates tokens locally, against the static +/// signing key, so no IdP is required to exercise auth. +/// The production BlogScope policy +/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs")) +/// registered verbatim. Tests that omit the bearer header exercise +/// the unauthenticated path and get 401. /// /// /// No IdentityServer, no SMTP, no static assets — the Org fixture @@ -36,6 +48,8 @@ namespace Yavsc.Blogs.Tests; /// public sealed class BlogsWebServerFixture : WebHostFixture { + private InMemoryDatabaseRoot? _inMemoryRoot; + protected override WebApplication BuildApp(WebApplicationBuilder builder) { // Use the real ApplicationDbContext with an in-memory store. @@ -43,8 +57,16 @@ public sealed class BlogsWebServerFixture : WebHostFixture // attempt to mock it would be wasted work; the real service // against an empty table returns an empty list, which is // exactly what the first test wants to assert. + // + // Share a single InMemoryDatabaseRoot across the test + // lifetime so POST + GET on the same fixture see the same + // store. Without the root, EF Core's In-Memory provider + // creates independent stores per DbContext in some + // configurations, and the second request would see an + // empty list even after the first wrote a row. + _inMemoryRoot = new InMemoryDatabaseRoot(); builder.Services.AddDbContext(opt => - opt.UseInMemoryDatabase("Yavsc.Blogs.Tests")); + opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)); // Trivial file-system auth: the GET index path never calls // into it, but the DI container needs an instance. @@ -53,28 +75,77 @@ public sealed class BlogsWebServerFixture : WebHostFixture // Real BlogSpotService — same instance the production host // builds (ApplicationDbContext, IAuthorizationService, - // IFileSystemAuthManager). + // IFileSystemAuthManager). With PermissionHandler registered + // below, Modify() now answers "is the caller the author of + // the post?" for real, which is exactly what we want to + // assert in the PUT tests. builder.Services.AddScoped(); + // The real PermissionHandler: BlogSpotService calls + // IAuthorizationService.AuthorizeAsync(user, blog, new + // EditPermission()) on Modify, and PermissionHandler + // resolves it via IsOwner(user, blog) — i.e. blog.AuthorId + // == user.GetUserId(). To PUT a post, the test JWT must + // carry sub == post.AuthorId. + builder.Services.AddScoped(); + // The BlogApiController is reached through MVC. AddControllers() // by default scans the test assembly only; we explicitly add the // Yavsc.Blogs application part so the controller is discovered // and routed. builder.Services.AddControllers() .AddApplicationPart(typeof(BlogApiController).Assembly); + + // Production BlogScope policy, verbatim. Two requirements: + // 1. RequireAuthenticatedUser: a request with no bearer + // token (or an invalid one) will be rejected. + // 2. RequireClaim("scope", "blogs"): the JWT must carry a + // "scope" claim whose value is "blogs". + // TestTokenIssuer.Issue() defaults to scope=blogs; the + // GetBlog_returns_401_when_no_token test omits the token + // entirely and asserts the policy fails closed. builder.Services.AddAuthorization(opt => { - // Mirror the production "BlogScope" policy: any - // authenticated user. The TestAuthPolicyProvider we - // register below short-circuits the role check via the - // X-Test-Role header. - opt.AddPolicy("BlogScope", p => p.RequireAssertion(_ => true)); + opt.AddPolicy("BlogScope", policy => + { + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "blogs"); + }); }); - // Test auth bypass — swapped in BEFORE the host builds the - // service collection, so it overrides any production - // policy provider registered by AddAuthorization above. - builder.Services.AddSingleton(); + // Real JWT Bearer authentication, sharing the signing key + // with TestTokenIssuer. No Authority → no OIDC discovery, + // no IdP roundtrip; the middleware validates the signature + // and the standard claims against the static configuration + // below. Production uses AddYavscJwtBearer with an IdP, but + // for the unit-test host that path is unwanted coupling. + builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.IncludeErrorDetails = true; + // MapInboundClaims = false here mirrors the + // JwtSecurityTokenHandler.DefaultInboundClaimTypeMap + // .Clear() in TestTokenIssuer: the validation + // pipeline must not rewrite "sub" to + // ClaimTypes.NameIdentifier, otherwise the + // PermissionHandler ownership check sees a null + // user id and rejects every PUT. + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = TestTokenIssuer.Issuer, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = TestTokenIssuer.SigningKey, + // "sub" stays "sub" (MapInboundClaims only + // remaps long Microsoft claim URIs, not sub). + // UserHelpers.GetUserId reads sub directly. + NameClaimType = "sub", + RoleClaimType = YavscConstants.RoleClaimType, + }; + }); return builder.Build(); } @@ -94,7 +165,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// file system, so the implementation can be a no-op. private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager { - public FileAccessRight GetFilePathAccess(ClaimsPrincipal user, string fileRelativePath) + public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) => FileAccessRight.None; public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) diff --git a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs new file mode 100644 index 00000000..18aa2e00 --- /dev/null +++ b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs @@ -0,0 +1,107 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Yavsc.Tests.Shared; + +/// +/// Mints HS256-signed JWTs for integration tests. The signing key is +/// held in a static field shared with the test host's +/// AddJwtBearer registration: whatever the host validates +/// against, this issuer signs with. +/// +/// +/// HS256 (symmetric) is the right choice for a unit-test issuer: +/// no key generation ceremony, no PEM round-trip, no asymmetric +/// crypto on the hot path. The key never leaves the test process. +/// Production continues to validate against the OIDC authority via +/// AddYavscJwtBearer — this issuer is *only* for the +/// in-process test host. +/// +/// +public static class TestTokenIssuer +{ + /// + /// Symmetric signing key shared with the test host's + /// TokenValidationParameters.IssuerSigningKey. + /// 32 bytes of zeros is enough entropy for HS256 *within the test + /// process*; the assertion we care about is "does the policy + /// evaluate a properly-signed token", not "is the key unguessable + /// by an attacker" (there is no attacker here). + /// + public static readonly SymmetricSecurityKey SigningKey = + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32))); + + /// + /// Issuer stamped into the iss claim and checked by the + /// test host. Must match + /// TokenValidationParameters.ValidIssuer. + /// + public const string Issuer = "yavsc-test-issuer"; + + /// + /// Audience stamped into the aud claim. The test host + /// does not validate audience (production may), so this is here + /// for shape only. + /// + public const string Audience = "yavsc-test"; + + private static bool _inboundClaimTypeMapCleared; + + /// + /// Mint a JWT carrying the given as + /// the sub claim, a single scope claim with value + /// , and any additional + /// . Token is valid for one hour + /// from now. + /// + /// Value of the sub claim. Read + /// back by UserHelpers.GetUserId, which is how + /// PermissionHandler.IsOwner identifies the author of a + /// BlogPost on PUT. + /// Value of the scope claim. The + /// production BlogScope policy requires + /// RequireClaim("scope", "blogs"). + /// Optional additional claims + /// (e.g. a role for an admin-bypass test). + public static string Issue( + string subject, + string scope = "blogs", + IEnumerable? extraClaims = null) + { + var now = DateTime.UtcNow; + var claims = new List + { + new("sub", subject), + new("scope", scope), + }; + if (extraClaims is not null) claims.AddRange(extraClaims); + + // JwtSecurityTokenHandler ships with a static + // DefaultInboundClaimTypeMap that rewrites short JWT claim + // names to their long Microsoft URIs at deserialisation + // time. The most relevant rewrite for us is + // "sub" → ClaimTypes.NameIdentifier. Without clearing the + // map, UserHelpers.GetUserId() — which reads the literal + // "sub" claim — would not find the value, PermissionHandler + // .IsOwner would compare against null, and the controller + // would return 401 on every PUT. Clearing is the standard + // way to opt out of the legacy mapping. + if (!_inboundClaimTypeMapCleared) + { + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + _inboundClaimTypeMapCleared = true; + } + + var creds = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index f9dc0876..b8fa9e64 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -16,5 +16,7 @@ + + \ No newline at end of file From 9e272a814768d37ea75b1fc9a12ae75541d8f72e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Tue, 7 Jul 2026 20:47:00 +0100 Subject: [PATCH 05/27] PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Login flow no longer needs a dedicated page. The OIDC interactive login now lives on the persistent SessionStatusBanner, alongside 'Se déconnecter', driven by a new SessionStatusViewModel.LoginAsync command. On success the VM raises LoginSucceeded and App.axaml.cs pushes MainPage on top of HomePage — same path BootAsync already takes when the silent refresh succeeds at boot, so the two flows can't drift apart (PushMainPageAsync helper, single source of truth). MainPage no longer shows an editable AuthorId field: the server infers the author from the bearer token, so the client-side control was misleading at best. The detail grid drops from 5 rows to 4. Removed: - Views/LoginPage.axaml + .axaml.cs - ViewModels/LoginPageViewModel.cs - HomePage Login button + OnLoginClick code-behind - DI registrations for LoginPage / LoginPageViewModel - ViewLocator mapping - dangling LoginPage* cref / comments in Platform.cs, PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml, YavscApiClient.cs --- .../PostIt.Android/PlatformBootstrap.cs | 8 +- .../PostIt.Desktop/PlatformBootstrap.cs | 4 +- src/PostIt/PostIt/App.axaml.cs | 31 +- src/PostIt/PostIt/Services/Platform.cs | 4 +- src/PostIt/PostIt/Services/YavscApiClient.cs | 12 +- src/PostIt/PostIt/ViewLocator.cs | 1 - .../PostIt/ViewModels/LoginPageViewModel.cs | 341 ------------------ .../ViewModels/SessionStatusViewModel.cs | 74 +++- src/PostIt/PostIt/Views/HomePage.axaml | 3 - src/PostIt/PostIt/Views/HomePage.axaml.cs | 27 -- src/PostIt/PostIt/Views/LoginPage.axaml | 75 ---- src/PostIt/PostIt/Views/LoginPage.axaml.cs | 66 ---- src/PostIt/PostIt/Views/MainPage.axaml | 5 +- src/PostIt/PostIt/Views/MainWindow.axaml | 6 +- .../PostIt/Views/SessionStatusBanner.axaml | 4 + 15 files changed, 119 insertions(+), 542 deletions(-) delete mode 100644 src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs delete mode 100644 src/PostIt/PostIt/Views/LoginPage.axaml delete mode 100644 src/PostIt/PostIt/Views/LoginPage.axaml.cs diff --git a/src/PostIt/PostIt.Android/PlatformBootstrap.cs b/src/PostIt/PostIt.Android/PlatformBootstrap.cs index 56a15fb0..0d11035a 100644 --- a/src/PostIt/PostIt.Android/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Android/PlatformBootstrap.cs @@ -5,10 +5,10 @@ namespace PostIt.Android; /// /// One-shot platform bootstrap. Called from -/// so that the shared -/// LoginPageViewModel sees the Android-specific redirect URI and a -/// working IBrowser (Chrome Custom Tabs) without referencing -/// Android APIs from the shared library. +/// so that the shared OIDC login +/// path sees the Android-specific redirect URI and a working +/// IBrowser (Chrome Custom Tabs) without referencing Android +/// APIs from the shared library. /// internal static class PlatformBootstrap { diff --git a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs index e93480fb..ad283ec1 100644 --- a/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs +++ b/src/PostIt/PostIt.Desktop/PlatformBootstrap.cs @@ -5,8 +5,8 @@ namespace PostIt.Desktop; /// /// One-shot platform bootstrap. Called from Program.Main so that -/// the shared LoginPageViewModel sees a working IBrowser -/// — the custom-scheme browser that hands the OIDC callback off to the +/// the shared OIDC login path sees a working IBrowser — the +/// custom-scheme browser that hands the OIDC callback off to the /// running instance through the named pipe. Desktop builds do NOT use /// a loopback HTTP listener: the postit:// scheme is registered /// with the OS at install time and the browser is whatever the user diff --git a/src/PostIt/PostIt/App.axaml.cs b/src/PostIt/PostIt/App.axaml.cs index 134ae42f..d9938f72 100644 --- a/src/PostIt/PostIt/App.axaml.cs +++ b/src/PostIt/PostIt/App.axaml.cs @@ -60,7 +60,6 @@ public partial class App : Application // Vues services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -71,7 +70,6 @@ public partial class App : Application services.AddSingleton(client); services.AddTransient(); services.AddTransient(); - services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -86,10 +84,9 @@ public partial class App : Application // Bind the canonical Settings to the static accessor so any // code path that can't easily take a constructor parameter - // (designer surfaces, Avalonia data templates, the - // LoginPage.axaml.cs fallback) still gets the same instance - // the rest of the app is using. Idempotent: re-binding from - // a second App boot (tests) is a no-op. + // (designer surfaces, Avalonia data templates) still gets + // the same instance the rest of the app is using. Idempotent: + // re-binding from a second App boot (tests) is a no-op. Settings.BindToServiceProvider(provider); Services = provider; @@ -125,6 +122,14 @@ public partial class App : Application _ = nav.PopToRootAsync(); }; + // When the user signs in interactively (Login button on + // the session banner), push MainPage on top of HomePage. + sessionStatus.LoginSucceeded += () => + { + var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!; + _ = PushMainPageAsync(provider, w); + }; + window.Opened += async (_, _) => await BootAsync(provider, api, window); } else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) @@ -154,10 +159,22 @@ public partial class App : Application sessionStatus.Refresh(); if (!refreshed) return; + await PushMainPageAsync(provider, window).ConfigureAwait(true); + } + + /// + /// Resolve a fresh MainPage + VM from DI and push it on top + /// of the current navigation stack. Used both by + /// (silent refresh at boot) and by SessionStatusViewModel.LoginSucceeded + /// (interactive login from the banner). Pulled out as a helper so + /// the two callers can't drift apart. + /// + private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window) + { var mainVm = provider.GetRequiredService(); var mainPage = provider.GetRequiredService(); mainPage.DataContext = mainVm; - await window.NavRoot.PushAsync(mainPage); + await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true); } private bool TryHandOffCustomSchemeUrl() diff --git a/src/PostIt/PostIt/Services/Platform.cs b/src/PostIt/PostIt/Services/Platform.cs index 258cd5b5..c867c63c 100644 --- a/src/PostIt/PostIt/Services/Platform.cs +++ b/src/PostIt/PostIt/Services/Platform.cs @@ -7,8 +7,8 @@ namespace PostIt.Services; /// Authorization Code + PKCE flow. The shared PostIt library does /// not reference any UI framework; platform projects (PostIt.Android, /// PostIt.Desktop, PostIt.Browser) populate this class once at startup so -/// the shared LoginPageViewModel can drive a native browser without -/// taking a hard dependency on any specific UI toolkit. +/// the shared OIDC login path can drive a native browser without taking +/// a hard dependency on any specific UI toolkit. /// public static class Platform { diff --git a/src/PostIt/PostIt/Services/YavscApiClient.cs b/src/PostIt/PostIt/Services/YavscApiClient.cs index 0a42773b..1fe02e86 100644 --- a/src/PostIt/PostIt/Services/YavscApiClient.cs +++ b/src/PostIt/PostIt/Services/YavscApiClient.cs @@ -57,7 +57,7 @@ public class YavscApiClient : IAsyncDisposable /// /// True if a non-expired access token (or a refreshable bundle) is - /// already in memory. UI uses this to skip the LoginPage on warm + /// already in memory. UI uses this to skip the login flow on warm /// starts. /// public bool HasValidSession @@ -74,9 +74,9 @@ public class YavscApiClient : IAsyncDisposable /// /// The current access token, or null if no session is active. - /// Surfaced so the LoginPageViewModel can mirror it onto its own - /// observable property (and so the OIDC id_token / claims can be - /// shown in the UI). + /// Surfaced so consumers (e.g. HomePage) can mirror it onto + /// their own observable properties and so the OIDC id_token / claims + /// can be shown in the UI. /// public string? CurrentAccessToken => _tokens?.AccessToken; @@ -87,9 +87,7 @@ public class YavscApiClient : IAsyncDisposable /// Optional sink for the discrete phases of /// the flow; the UI uses this to render a debug-friendly status /// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode - /// → Success / Error). The same caller can also rely on - /// for the human - /// text (URLs, error detail). + /// → Success / Error). public async Task LoginInteractiveAsync( IProgress? progress = null, CancellationToken ct = default) diff --git a/src/PostIt/PostIt/ViewLocator.cs b/src/PostIt/PostIt/ViewLocator.cs index e6d0e91a..ba34bf7c 100644 --- a/src/PostIt/PostIt/ViewLocator.cs +++ b/src/PostIt/PostIt/ViewLocator.cs @@ -27,7 +27,6 @@ public class ViewLocator : IDataTemplate { MainPageViewModel => _services.GetRequiredService(), SettingsPageViewModel => _services.GetRequiredService(), - LoginPageViewModel => _services.GetRequiredService(), HomePageViewModel => _services.GetRequiredService(), SignaturePageViewModel => _services.GetRequiredService(), _ => new TextBlock { Text = $"No view for {data.GetType().Name}" } diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs deleted file mode 100644 index a6ed595d..00000000 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System; -using System.IO; -using System.Threading.Tasks; -using CommunityToolkit.Mvvm.Input; -using IdentityModel.OidcClient.Browser; -using PostIt.Services; - -namespace PostIt.ViewModels; - -public partial class LoginPageViewModel : ViewModelBase -{ - private const string SettingsFileName = "postit-settings.json"; - - [Obsolete("Password grant is not used; IdentityModel.OidcClient performs PKCE.")] - public string Password { get; set; } = string.Empty; - - [Obsolete("User-entered email is not used; the IdP login UI collects it.")] - public string UserEmail { get; set; } = string.Empty; - - [Obsolete("No local credential persistence in the current build.")] - public bool RememberMe { get; set; } - - /// - /// URL of the Yavsc.Org account-registration page. - /// Derived from 's Authority. - /// Empty when the authority is not configured. - /// - public string RegisterUrl => - BuildExternalUrl("/Account/Register"); - - /// - /// URL of the Yavsc.Org password-reset page (open to anonymous users). - /// Derived from 's Authority. - /// Empty when the authority is not configured. - /// - public string ForgotPasswordUrl => - BuildExternalUrl("/Account/ForgotPassword"); - - public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl); - public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl); - - /// - /// Canonical authority with any trailing - /// slash removed. Used as the base for both the OIDC discovery URL and the - /// human-facing Account URLs (Register / Forgot password). Empty when the - /// authority is not configured. - /// - public string ExternalUrl => BuildExternalUrl(string.Empty); - - /// - /// OIDC discovery URL the client actually calls during login: - /// ExternalUrl + "/.well-known/openid-configuration". Surfaced in - /// on failure so the operator can copy it - /// verbatim and verify reachability from a browser. - /// - public string DiscoveryUrl => - string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration"; - - /// - /// True when the settings file is missing or Authentication.Authority - /// is empty. The LoginPage surfaces a banner in that case and disables - /// the Register / Forgot password buttons. - /// - public bool ConfigMissing => - string.IsNullOrWhiteSpace(Settings.Authentication?.Authority); - - /// - /// Localised banner shown when is true. - /// The path follows the XDG spec on Linux (where PostIt.Desktop runs): - /// the file is expected at ~/.config/PostIt/postit-settings.json. - /// - public string ConfigMissingMessage => - $"Configuration PostIt manquante — voir ~/.config/PostIt/postit-settings.json"; - - private string BuildExternalUrl(string path) - { - var authority = Settings.Authentication?.Authority?.TrimEnd('/'); - return string.IsNullOrEmpty(authority) - ? string.Empty - : authority + path; - } - - /// - /// The access token of the most recent successful login, or null. - /// Kept on the VM so views can show "logged in as …" feedback; the - /// authoritative copy lives in the . - /// - private string? _accessToken; - public string? AccessToken - { - get => _accessToken; - private set => this.SetProperty(ref _accessToken, value); - } - - public override bool CanNavigateNext { get => false; protected set => throw new NotImplementedException(); } - public override bool CanNavigatePrevious { get => true; protected set => throw new NotImplementedException(); } - - public Settings Settings { get; } - - /// - /// Discrete phase of the OIDC flow the LoginPage is currently - /// showing. Surfaced in the UI as a one-line status (Discovering / - /// OpeningBrowser / AwaitingCallback / ExchangingCode / Success / - /// Error). Operators use this to debug the custom-scheme - /// callback hand-off: when AwaitingCallback never resolves, - /// the OS never re-launched PostIt with the postit:// URL. - /// - private OIDCLoginPhase _phase = OIDCLoginPhase.Idle; - public OIDCLoginPhase Phase - { - get => _phase; - private set - { - if (this.SetProperty(ref _phase, value)) - OnPropertyChanged(nameof(PhaseLabel)); - } - } - - /// - /// Human-readable label for . French to match - /// the rest of the UI. Computed once per phase change. - /// - public string PhaseLabel => _phase switch - { - OIDCLoginPhase.Idle => "En attente", - OIDCLoginPhase.Discovering => "Découverte OIDC…", - OIDCLoginPhase.OpeningBrowser => "Ouverture du navigateur…", - OIDCLoginPhase.AwaitingCallback => "En attente du callback postit://…", - OIDCLoginPhase.ExchangingCode => "Échange du code contre les jetons…", - OIDCLoginPhase.Success => "Connecté", - OIDCLoginPhase.Error => "Erreur", - _ => _phase.ToString(), - }; - - private string _statusMessage = "Ready"; - public string StatusMessage - { - get => _statusMessage; - private set => this.SetProperty(ref _statusMessage, value); - } - - private bool _isBusy; - public bool IsBusy - { - get => _isBusy; - private set => this.SetProperty(ref _isBusy, value); - } - - private bool _LoginSuccess; - public bool LoginSuccess { get => _isBusy; - private set => this.SetProperty(ref _LoginSuccess, value); } - - /// - /// Optional override used by tests. When set, this factory is called - /// instead of to obtain the - /// instance. - /// - public Func? BrowserFactoryOverride { get; set; } - - /// - /// Optional override used by tests. When set, this delegate replaces - /// the call to at the start of - /// , so tests can inject a Settings object - /// without it being overwritten by the user/embedded default. - /// - public Func? SettingsLoadOverride { get; set; } - - /// - /// Optional override used by tests. When set, the VM hands this - /// pre-built to itself instead of - /// constructing a fresh one. - /// - public YavscApiClient? ApiClientOverride { get; set; } - public Action LoginSucceeded { get; internal set; } - - private YavscApiClient? _api; - - /// - /// Designer / Avalonia-data-template fallback. Resolves the - /// canonical Settings singleton through the running App's DI - /// container. Throws when called outside a bound App (e.g. a - /// stray unit test instantiating the VM directly) so we cannot - /// silently end up with a second Settings instance racing the - /// singleton at runtime — that race is the exact bug that - /// crashed postit://callback re-launches. Tests that - /// don't want the DI bind pass an explicit Settings to - /// the parameterised constructor. The cross-thread crash is - /// also fixed at the Settings layer (thread-safe PropertyChanged - /// marshalling) so the duplicate-instance race is now caught - /// loudly instead of corrupting Avalonia state. - /// - public LoginPageViewModel() : this(Settings.RequireCurrent(), apiClient: null, browserFactoryOverride: null) - { - // Load settings eagerly so RegisterUrl / ForgotPasswordUrl are - // populated as soon as the page renders (XAML bindings fire - // before the user clicks Login). Settings.Load is synchronous - // on purpose; calling .GetAwaiter().GetResult() on it would - // deadlock the UI thread on the await inside the file read. - try { Settings.Load(); } - catch { /* settings may be missing in tests/dev; LoginAsync will surface real errors */ } - } - - /// - /// Test-friendly constructor: caller supplies pre-loaded - /// , an optional - /// that bypasses the - /// static indirection, and an optional - /// pre-built for end-to-end - /// scenarios where the test owns the wiring. - /// - public LoginPageViewModel( - Settings settings, - Func? browserFactoryOverride = null, - YavscApiClient? apiClient = null) - { - Settings = settings; - BrowserFactoryOverride = browserFactoryOverride; - ApiClientOverride = apiClient; - StatusMessage = "Ready"; - } - - [RelayCommand] - public async Task LoginAsync() - { - try - { - IsBusy = true; - LoginSuccess = false; - if (SettingsLoadOverride is not null) - await SettingsLoadOverride().ConfigureAwait(false); - else - Settings.Load(); - - // Guard: refuse to call OidcClient when the authority is - // empty. IdentityModel would otherwise build a bogus - // authorize URL like "http://127.0.0.1:1/" from an empty - // Authority, which the browser refuses with a confusing - // "Cette adresse est interdite"-style message. Tell the - // operator exactly what to fix instead. - if (string.IsNullOrWhiteSpace(Settings.Authentication?.Authority)) - { - IsBusy = false; - StatusMessage = - $"Configuration manquante — édite {SettingsFileHint()} et renseigne Authentication.Authority"; - return; - } - - // The platform project picks the right redirect URI and - // browser implementation; we don't reference any UI - // toolkit from here. - Settings.RedirectUri = string.IsNullOrWhiteSpace(Settings.RedirectUri) - ? Platform.DefaultRedirectUri - : Settings.RedirectUri; - - // Surface the discovery URL the client is about to call, - // so a failure (DNS, TLS, 404) can be diagnosed by - // pasting the URL straight into a browser. OidcClient - // computes the discovery URL as - // `Authority + /.well-known/openid-configuration`; we - // normalise the trailing slash here so the printed URL is - // exactly what IdentityModel will fetch. - if (!string.IsNullOrEmpty(DiscoveryUrl)) - StatusMessage = $"Discovering {DiscoveryUrl}"; - - // Build (or reuse) the API client. The browser override - // takes precedence: tests want to inject a fake browser - // and the production path uses Platform.CreateBrowser. - _api ??= ApiClientOverride ?? new YavscApiClient(Settings, BuildTokenStore()); - - // Platform.CreateBrowser may still want to be customised - // per-call (e.g. between desktop and android), so route - // the interactive login through a callback that reuses - // BrowserFactoryOverride when present. - // - // The progress sink drives Phase / PhaseLabel; StatusMessage - // keeps the text detail (URLs, error messages). Same - // underlying flow, two views. - var progress = new Progress(p => Phase = p); - await LoginInteractiveCoreAsync(_api, progress); - - IsBusy = false; - AccessToken = _api.CurrentAccessToken; - StatusMessage = "Interactive token acquired."; - LoginSuccess = true; - LoginSucceeded?.Invoke(); - } - catch (Exception ex) - { - IsBusy = false; - var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty; - StatusMessage = $"Error: {ex.Message}{suffix}"; - } - } - - /// - /// Single entry point for the OIDC login: YavscApiClient owns the - /// browser choice, the OidcClient instance, the token persistence - /// and the refresh path. The VM is just a thin coordinator. - /// - private async Task LoginInteractiveCoreAsync( - YavscApiClient api, - IProgress? progress = null) - { - var original = Platform.CreateBrowser; - try - { - if (BrowserFactoryOverride is not null) - Platform.CreateBrowser = BrowserFactoryOverride; - - await api.LoginInteractiveAsync(progress).ConfigureAwait(false); - } - finally - { - Platform.CreateBrowser = original; - } - } - - /// - /// XDG-compliant path to the user settings file. Surfaced in the - /// "Configuration manquante" message so the operator knows exactly - /// which file to edit without having to dig through docs. - /// - private static string SettingsFileHint() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - return Path.Combine(appData, "PostIt", "postit-settings.json"); - } - - /// - /// Build the on-disk used by - /// . The token bundle lives in - /// ~/.config/PostIt/tokens.json on Linux; the same path - /// layout is used on every platform for predictability. - /// - private static TokenStore BuildTokenStore() - { - var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); - var path = Path.Combine(appData, "PostIt", "tokens.json"); - return new TokenStore(path); - } -} diff --git a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs index 0ebe73a9..9902008f 100644 --- a/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/SessionStatusViewModel.cs @@ -1,3 +1,5 @@ +using System; +using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using PostIt.Services; @@ -9,7 +11,11 @@ namespace PostIt.ViewModels; /// MainWindow.axaml. Mirrors 's /// session state ("Connecté" / "Déconnecté") and exposes a /// Logout command that purges the token store and asks the -/// navigation owner to route the user back to HomePage. +/// navigation owner to route the user back to HomePage, plus +/// a Login command that drives the OIDC interactive flow +/// and raises a event on success so +/// MainWindow can push MainPage on top of +/// HomePage. /// /// Construction is deferred until the API client exists; the /// App.axaml.cs wiring sets after building both, @@ -21,12 +27,29 @@ public partial class SessionStatusViewModel : ViewModelBase /// App.axaml.cs listens and swaps the navigation root. public event System.Action? LogoutCompleted; + /// Raised after acquired a valid session; + /// App.axaml.cs listens and pushes MainPage on top of + /// HomePage so the user lands on the blog editor. + public event System.Action? LoginSucceeded; + [ObservableProperty] public partial bool IsLoggedIn { get; private set; } + /// Inverse of , for XAML bindings + /// (the banner shows the Login button when the user is logged out). + /// Updated from . + [ObservableProperty] + public partial bool IsLoggedOut { get; private set; } = true; + [ObservableProperty] public partial string SessionLabel { get; private set; } = "Déconnecté"; + /// True while a Login flow is in flight; the Login button + /// binds IsEnabled to !IsBusy via + /// 's CanExecute. + [ObservableProperty] + public partial bool IsBusy { get; private set; } + /// The API client backing the banner. Set once at startup; /// the banner polls HasValidSession on demand rather than /// subscribing to a stream — the session state only changes at @@ -50,9 +73,58 @@ public partial class SessionStatusViewModel : ViewModelBase { var has = Api?.HasValidSession ?? false; IsLoggedIn = has; + IsLoggedOut = !has; SessionLabel = has ? "Connecté" : "Déconnecté"; } + /// + /// Override the banner label with an error message. Used when + /// an interactive login attempt fails so the operator sees + /// something on the persistent UI without us needing a + /// dedicated error page. The next call + /// reverts to "Connecté" / "Déconnecté". + /// + public void SetError(string message) + { + IsLoggedIn = false; + IsLoggedOut = true; + SessionLabel = message; + } + + /// + /// Drive the OIDC interactive login. On success, refreshes + /// the banner state and raises so + /// the navigation owner can push MainPage. On failure, + /// surfaces the error in the banner via . + /// + [RelayCommand(CanExecute = nameof(CanLogin))] + public async Task LoginAsync() + { + if (Api is null) return; + IsBusy = true; + try + { + await Api.LoginInteractiveAsync().ConfigureAwait(true); + } + catch (Exception ex) + { + SetError($"Login failed: {ex.Message}"); + return; + } + finally + { + IsBusy = false; + } + + Refresh(); + if (Api.HasValidSession) + LoginSucceeded?.Invoke(); + } + + private bool CanLogin() => !IsBusy; + + partial void OnIsBusyChanged(bool value) => LoginCommand.NotifyCanExecuteChanged(); + [RelayCommand] public async System.Threading.Tasks.Task LogoutAsync() { diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/HomePage.axaml index bb7204d6..82473542 100644 --- a/src/PostIt/PostIt/Views/HomePage.axaml +++ b/src/PostIt/PostIt/Views/HomePage.axaml @@ -9,8 +9,5 @@ FontSize="22" FontWeight="SemiBold" HorizontalAlignment="Center"/> - public event System.Action? LoginSucceeded; + /// Raised when the user clicks the "Paramètres" button on + /// the session banner. App.axaml.cs listens and pushes + /// SettingsPage (resolved from DI, bound to the canonical + /// Settings singleton) on top of the current navigation + /// stack. Same event pattern as and + /// so the VM stays decoupled from + /// NavigationPage / window lifetime. + public event System.Action? OpenSettingsRequested; + [ObservableProperty] public partial bool IsLoggedIn { get; private set; } @@ -133,4 +142,11 @@ public partial class SessionStatusViewModel : ViewModelBase Refresh(); LogoutCompleted?.Invoke(); } + + [RelayCommand] + public async System.Threading.Tasks.Task OpenSettingsCommand() + { + OpenSettingsRequested?.Invoke(); + await System.Threading.Tasks.Task.CompletedTask; + } } diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/ViewModels/Settings.cs similarity index 78% rename from src/PostIt/PostIt/Settings/Settings.cs rename to src/PostIt/PostIt/ViewModels/Settings.cs index 3cb40e38..12ed2e02 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/ViewModels/Settings.cs @@ -2,17 +2,17 @@ using System.Runtime.CompilerServices; using CommunityToolkit.Mvvm.ComponentModel; using IdentityModel.OidcClient; using Microsoft.Extensions.DependencyInjection; -using PostIt.Services; using System; +using System.Collections.Generic; using System.IO; using System.Text.Json; using System.Threading; [assembly: InternalsVisibleTo("PostIt.Tests")] -namespace PostIt; +namespace PostIt.ViewModels; -public partial class Settings : ObservableObject +public partial class Settings : ViewModelBase { const string SettingsFileName = "postit-settings.json"; @@ -36,13 +36,7 @@ public partial class Settings : ObservableObject /// public const string AndroidRedirectUri = "android://postit-signin"; - /// - /// Default custom-scheme redirect URI on Desktop. The OS routes the - /// callback to the running PostIt instance via the named-pipe - /// hand-off in - /// (RFC 8252 §7.1). Production Desktop builds use this. - /// - public const string DefaultDesktopRedirectUri = "postit://callback"; + /// /// Process-wide canonical instance, wired up @@ -107,20 +101,11 @@ public partial class Settings : ObservableObject public partial bool DarkMode { get; set; } = false; [ObservableProperty] - public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; - - /// - /// OAuth redirect URI. Defaults to - /// (custom URI scheme) which is the right answer for desktop - /// production builds. Mobile platforms must set this to - /// before calling LoginAsync. - /// - [ObservableProperty] - public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; - + public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; [ObservableProperty] - public partial string[] Scopes { get; set; } + public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/"; + public bool Loaded { get; private set; } = false; /// @@ -154,8 +139,8 @@ public partial class Settings : ObservableObject { Authority = Authentication.Authority, ClientId = Authentication.ClientId, - RedirectUri = RedirectUri, - Scope = string.Join(' ', this.Scopes), + RedirectUri = Authentication.RedirectUri, + Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)), TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody, PostLogoutRedirectUri = "https//yavsc.pschneider.fr", // PKCE is enabled by default when no client_secret is provided. @@ -168,6 +153,48 @@ public partial class Settings : ObservableObject } } + /// + /// Scopes the PostIt client always requires from the OIDC provider, + /// regardless of what the user has in their settings file. + /// + /// PostIt calls into the Blog API (and any other Yavsc API + /// gated by an [Authorize("…Scope")] policy) and is silent + /// about the contract: a missing scope here surfaces as a 401 + /// on the very first API call after login, with no obvious link + /// to the settings. The "feature" scopes the user must opt into + /// (e.g. blogs) are still their choice — we only force the + /// structural ones that OIDC itself needs. + /// + private static readonly string[] BuiltInScopes = new[] + { + "openid", // OIDC: required for the id_token + "profile", // OIDC: standard profile claims + "offline_access" // OIDC: required to receive a refresh_token + }; + + /// + /// Merge user-configured scopes with the built-in ones. User scopes + /// come first (preserves author intent), then the built-ins, with + /// duplicates removed case-sensitively. null or empty input + /// is fine — we still emit the built-ins. + /// + internal static IEnumerable MergeScopes(string[]? userScopes) + { + var seen = new HashSet(StringComparer.Ordinal); + if (userScopes is not null) + { + foreach (var s in userScopes) + { + if (string.IsNullOrWhiteSpace(s)) continue; + if (seen.Add(s)) yield return s; + } + } + foreach (var s in BuiltInScopes) + { + if (seen.Add(s)) yield return s; + } + } + internal void Load() { if (Loaded) return; @@ -280,9 +307,17 @@ public partial class Settings : ObservableObject { this.Authentication = settings.Authentication; this.DarkMode = settings.DarkMode; - this.ApiUrl = settings.ApiUrl; - this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri; - this.Scopes = settings.Scopes; + if (!(settings.Authentication is null)) + { + this.Authentication = new AuthenticationSettings(); + this.Authentication.Authority = string.IsNullOrWhiteSpace(settings.Authentication.Authority) ? + AuthenticationSettings.DefaultAuthority : settings.Authentication.Authority; + this.Authentication.ClientId = string.IsNullOrWhiteSpace(settings.Authentication.ClientId) ? + AuthenticationSettings.DefaultClientId : settings.Authentication.ClientId; + this.Authentication.RedirectUri = string.IsNullOrWhiteSpace(settings.Authentication.RedirectUri) ? + AuthenticationSettings.DefaultDesktopRedirectUri : settings.Authentication.RedirectUri; + this.Authentication.Scopes = settings.Authentication.Scopes; + } } } catch (Exception ex) @@ -291,31 +326,6 @@ public partial class Settings : ObservableObject } } - /// - /// Marshals every - /// notification onto the Avalonia UI thread before it leaves this - /// instance. Without this, a background worker (OIDC discovery - /// running on a Task, the file I/O continuation in , - /// any HTTP callback) would raise PropertyChanged from a - /// thread-pool thread and Avalonia's binding sink would then reach - /// into DataValidationErrors.SetErrors from off-thread, - /// blowing up with InvalidOperationException: The calling thread - /// cannot access this object because a different thread owns it. - /// We keep the mutation lock separate (above) and let the property - /// setters do their work synchronously — only the notification - /// fan-out is bounced to the UI thread. - /// - protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) - { - if (UiDispatcher.IsOnUiThread) - { - base.OnPropertyChanged(e); - return; - } - // Capture by value: the args object is mutable in some binding - // sinks, and we don't want a background thread to keep mutating - // it after we hand it to the dispatcher. - var snapshot = new System.ComponentModel.PropertyChangedEventArgs(e.PropertyName); - UiDispatcher.Post(() => base.OnPropertyChanged(snapshot)); - } + public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } + public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } } diff --git a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs b/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs deleted file mode 100644 index 67223d5f..00000000 --- a/src/PostIt/PostIt/ViewModels/SettingsViewModel.cs +++ /dev/null @@ -1,18 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; - -namespace PostIt.ViewModels; - -public partial class SettingsPageViewModel : ViewModelBase -{ - [ObservableProperty] - public partial bool DarkMode { get; set; } - - [ObservableProperty] - public partial string Authority { get; set; } - - [ObservableProperty] - public partial string ClientId { get; set; } - - public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } - public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } -} diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml index 0af0e120..9c6a3f93 100644 --- a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml +++ b/src/PostIt/PostIt/Views/SessionStatusBanner.axaml @@ -21,6 +21,9 @@ Command="{Binding LoginCommand}" IsVisible="{Binding IsLoggedOut}" DockPanel.Dock="Right"/> +