postIt #2
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "postIt"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.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).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 <c>LoginPage*</c> cref / comments in Platform.cs, PlatformBootstrap.cs (Desktop + Android), MainWindow.axaml, YavscApiClient.csTwo leftover bits of dead code that were just compiler noise: - Settings.folder (IStorageFolder?, never read) plus the three Avalonia / Avalonia.Platform.Storage usings that only existed to type it. The picker-based flow was replaced by a direct file-path read in Settings.Load, so the field has been a CS0414 for a while. Just delete it. - ViewLocator.Build / Match took 'object data' while the IDataTemplate interface expects 'object? data', which is why the compiler was complaining with CS8767 about nullability mismatch on every implementation. Add an explicit null arm in the switch so the default branch doesn't have to dereference a possibly-null data either.All [ApiController] classes (BlogApi, BlogTags, PostTags, FileSystem, FileSystemStream, Comments, TagsApi) returned 404 on every route, even though Kestrel was up. The pipeline in Program.cs was missing MapControllers(), so the controllers were never attached to the endpoint data source. MapIdentityApi and MapGet("/identity") worked because they're explicit minimal-API routes; the attribute- routed controllers didn't. Confirmed end-to-end: GET /api/v1/blog now returns 401 (auth required by [Authorize("BlogScope")]) instead of 404.The endpoint was a copy-paste from the IdentityServer template documentation. It serialised the entire HttpContext.User claim set to anonymous JSON, with no auth gate. In a public-facing deployment that's exactly the kind of surface scrapers and botnets love (it tells them whether their token is valid and what shape the issuer uses), and it served no production purpose. Side benefit: removes the ASP0004 analyser warning ("IActionResult should not be returned from a MapGet Delegate") that came with this line.Two intertwined jobs here: 1. Diagnostic test for the production 401 we see when PostIt talks to Yavsc.Blogs. The hypothesis this test isolates: the access token sent on the wire is missing the 'blogs' scope that Yavsc.Blogs' BlogScope policy requires (see Yavsc.Blogs/Program.cs: RequireClaim(JwtClaimTypes.Scope, "blogs")). The test fakes a single HttpMessageHandler, captures the outbound bearer, decodes the JWT, and asserts the 'scope' claim contains 'blogs'. It does not stand up a server, an OIDC stub, or any network listener. Result: the scope is present in the access_token we construct, so the 401 is not on the client side — most likely the IdP at Yavsc.Org is not issuing 'blogs' as a recognised scope. 2. Mechanical fix of the three test files that broke during the Settings model refactor (PostIt.Settings -> PostIt.ViewModels.Settings; ApiUrl -> BusinessApiUrl; Scopes/RedirectUri moved under Authentication; DefaultDesktopRedirectUri is on AuthenticationSettings in the global namespace). Also restored the BaseAddress setup that BlogApiClient does in production in LoginAndPersistAsync / the reloaded-client path of YavscApiClientTests, so the two integration tests that call CallAsync("posts") directly don't trip on 'request URI must be absolute or BaseAddress must be set'. Test status: 45 / 45 passing in PostIt.Tests.Two changes to the PostIt settings surface, both in service of the same observation: opening the Settings page did not reflect the loaded state, and edits to Authority / ClientId did not persist. 1. Settings was registered twice in the DI container: once as a singleton (the already-Load()'d instance) and again as a transient, with the transient registration winning. The Settings page's DataContext was therefore a brand-new, empty Settings instance on every push — Authority and ClientId bound to null, and even if the user typed into the fields, the edits landed on the throwaway instance and were silently lost. The fix is the obvious one: keep Settings as a singleton and drop the transient override. 2. The Scopes field of AuthenticationSettings is a string[], which doesn't bind to a TextBox without a converter. The Settings page already shows the other auth fields as plain TextBoxes, so the same treatment is given to scopes via a new space-separated view property: - AuthenticationSettings.ScopeListText (string, [ObservableProperty], [JsonIgnore]) is the view. - OnScopeListTextChanged splits on any whitespace and re-assigns Scopes, skipping the write when the parsed array is element-wise equal to the current one to avoid a PropertyChanged loop with OnScopesChanged. - OnScopesChanged keeps ScopeListText in sync when Scopes is reassigned from outside (JSON hydration, MergeScopes, programmatic updates), again short- circuiting when the textual representation hasn't changed so the TextBox caret doesn't flicker on load. - RefreshScopeListText is the explicit re-sync entry point; Settings.ApplyJson calls it after a successful hydration to normalise any whitespace the JSON might have introduced. SettingsPage.axaml gets a new Scopes row between ClientId and the Blogs API URL; the Grid.RowDefinitions are bumped to 13 to match. Scopes remains the on-disk format — only ScopeListText is presentation. The shape of the on-disk postit-settings.json is unchanged: [JsonIgnore] on ScopeListText, and the serialization path in Settings still round-trips Scopes directly. MergeScopes in Settings.GetOidcClientOptions is untouched. Tests: 3/3 SettingsLoadTests passing (PostIt.Tests); PostIt.csproj builds clean (0 errors). The other PostIt.Tests suites depend on the OIDC stub WebApplicationFactory and time out on this network-restricted host, so we trust the unit-level coverage and the build.Two related changes that close the loop on the SettingsPage push semantics. 1. The SettingsPage used to be registered as Transient. Each click on the Paramètres button resolved a fresh instance, re-bound it to the Settings singleton, and pushed it onto the navigation stack. Repeated clicks accumulated stacked instances, each fully bound, and the user had to tap Back N times to leave. The fix is to register the page as a Singleton in the DI container. There is now one and only one SettingsPage ContentPage for the lifetime of the app: - its DataContext is wired once, at composition time (just after the ViewLocator is added to DataTemplates), not on every push; - the OpenSettingsRequested handler is a pure navigation concern, with no DI resolution and no rebinding; - the in-memory Settings state is preserved across visits (any in-flight edit stays in the same instance). 2. The OpenSettingsRequested handler is guarded so that if the SettingsPage is already at the top of NavigationStack, the push is a no-op. NavigationPage.PushAsync does not deduplicate; without the guard, calling it twice with the same instance pushes it a second time, and the user has to tap Back twice to leave. The guard is a reference comparison on NavigationStack[Count - 1] against the singleton instance, which is correct precisely because the page is a singleton. doc/architecture/postit.md is updated to match: the DI table reflects the new lifetime, and the 'Garde anti-empilement' section is rewritten from 'to be implemented' to the actual implementation, including the rationale for reference comparison and the cross-dependency between the singleton lifetime and the guard. The Settings-singleton invariant (in the same doc) is unchanged: Settings is still a singleton, and adding a transient override would still be the bug it always was. The new SettingsPage singleton sits alongside it cleanly. Build: 0 errors. Tests: 3/3 SettingsLoadTests green.