Compare commits

...

28 commits

Author SHA1 Message Date
8e8c558295 Merge pull request 'postIt' (#2) from postIt into main
Some checks failed
Dotnet build and test / log-the-inputs (push) Has been cancelled
Dotnet build and test / build (push) Has been cancelled
Reviewed-on: #2
2026-07-11 02:57:28 +01:00
abfc68a809 Just post one, at least
Some checks failed
Dotnet build and test / log-the-inputs (pull_request) Has been cancelled
Dotnet build and test / build (pull_request) Has been cancelled
2026-07-11 02:56:14 +01:00
03cd9843d3 PostIt/Yavsc.Blogs: surface 4xx body + pin controller + red UI test for Save
The 'Save' button in PostIt has been returning 400 from
/api/v1/blog ever since the editor's title and article fields
were re-bound to SelectedPost.Title / SelectedPost.Article.
The user types into the editor, taps Save, the controller
rejects with 'The Title field is required', and the PostIt
status bar shows only the generic 'Response status code does
not indicate success: 400' — no field name, no reason.

Three pieces here make the regression diagnosable and pin a
test for the fix:

1. YavscApiClient: replace EnsureSuccessStatusCode() at both
   call sites with a small helper that reads the response
   body and embeds it in the thrown HttpRequestException. The
   VM's existing catch (Exception) in ExecuteAsync forwards
   ex.Message to the status bar, so the next 'click Save'
   tells the user exactly which field the server rejected.

2. Yavsc.Blogs.Tests: two integration tests on the real
   controller (no HTTP mock) — one pins that a well-formed
   PostIt-shaped payload (Title + Article + AuthorId + dates,
   Id=0) is accepted with 201, the other pins that a payload
   with Title=string.Empty is rejected with 400. Together they
   pin the contract the VM has to honour.

3. PostIt.Tests: a red [AvaloniaFact] UI test that mounts
   MainPage inside a headless Window, types a title into the
   TextBox without first selecting a post in the list, taps
   Save, and asserts the body of the first POST contains
   the typed title. Today this test fails with Title='',
   reproducing the production 400. The matching fix (a
   Title/Article buffer on MainPageViewModel that the XAML
   binds to, and that Save uses to build the outgoing
   BlogPost) is the next commit; the test is the safety net.
2026-07-11 02:52:50 +01:00
56846e2781 Log the identity main params 2026-07-10 00:27:18 +01:00
58c08fe2fc auto refresh token 2026-07-09 23:34:15 +01:00
fdb7acca56 using Material.Avalonia 2026-07-09 23:28:46 +01:00
5104ffeb81 postIt: wire DarkMode, drop dead themeVariant, add UI tests for the banner
Three related changes that close the loop on the DarkMode
field and lay the first stone of a UI test scaffold for
PostIt.

1. Settings.DarkMode was previously a dead field. It round-
   tripped through postit-settings.json and the SettingsPage
   CheckBox, OnDarkModeChanged flipped IsDirty, and that was
   it — no consumer ever read the value, so toggling the
   CheckBox had no visible effect. The fix is in
   App.OnFrameworkInitializationCompleted: read the value
   Load() just populated and set
   Application.Current.RequestedThemeVariant accordingly
   (so a dark-mode user lands on a dark window on first
   launch, not on a default-light window that flips after
   the user touches the toggle), then subscribe to
   settings.PropertyChanged and update the theme on every
   DarkMode change. The consumer lives in App.axaml.cs, not
   in Settings, so the Settings model stays free of any
   Avalonia.Application dependency and the SettingsLoadTests
   (which construct Settings outside an Avalonia host)
   still pass unchanged.

2. MainPageViewModel had a vestigial [ObservableProperty]
   ThemeVariant themeVariant = ThemeVariant.Default that no
   XAML, no code, and no test ever read. It was the start of
   a half-finished attempt to expose the theme variant on
   the page VM. The dark-mode wiring above makes it
   irrelevant: the theme is now driven by Application, not
   by a VM property. The field is removed, along with the
   using Avalonia.Styling; it pulled in (now unused).

3. SessionStatusBannerTests adds the first set of UI tests
   for PostIt. They mount a real MainWindow via the headless
   Avalonia host declared in TestApp.cs, attach a
   SessionStatusViewModel as the banner's DataContext, and
   assert the actual visual tree contents: three buttons
   render (Se déconnecter, Se connecter, Paramètres), the
   Login button is visible when logged out, the Logout
   button is hidden when logged out, the Paramètres button
   is visible regardless of session, and the session label
   text reflects the VM. The pattern follows what
   UnitTest1.MainPage_Should_Load already established:
   [AvaloniaFact] (from Avalonia.Headless.XUnit) plus
   new MainWindow() / window.Show(). A plain [Fact] cannot
   drive Window..ctor() because the headless platform's
   PlatformManager.CreateWindow() has no service registered
   outside a dispatcher-aware test context; the
   AvaloniaFact attribute provides that context. The
   DataContext is set on the banner directly because
   App.OnFrameworkInitializationCompleted is not called in
   a unit test (production wiring is exercised by the
   manual launch, not here).

Build: 0 errors. Tests: 5/5 SessionStatusBannerTests,
3/3 SettingsLoadTests, 1/1 MainPageTests (the existing
scaffold test, unchanged). The other PostIt.Tests suites
depend on the OIDC stub WebApplicationFactory and time out
on this network-restricted host.
2026-07-09 22:41:43 +01:00
1733dababb postIt: SettingsPage is a singleton, navigation is idempotent
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.
2026-07-09 22:00:00 +01:00
120bae6f5c doc(arch): PostIt topology + up-to-date project list
Two long-standing gaps in the architecture documentation are
filled in this commit:

1. doc/architecture/postit.md is new. It covers everything the
   existing postit-oidc.md does not: the one-codebase /
   three-frontends topology (PostIt lib + PostIt.Desktop +
   PostIt.Android + PostIt.Browser), the custom ViewLocator
   that resolves ViewModel -> View through the DI provider
   (and why we don't use the Avalonia.Mvvm default), the
   composition root in App.OnFrameworkInitializationCompleted
   with the full DI registration table, the navigation flow
   driven by SessionStatusViewModel events, the ViewModel
   lifetime conventions (singleton vs transient), the
   [RelayCommand] XAML binding conventions (referenced to
   AGENTS.md for the canonical version), and the per-page
   DataContext / role table. The Settings-singleton invariant
   is called out as a guard rail, and the SettingsPage
   anti-empilement invariant is documented as the TODO the
   code still owes us.

2. doc/architecture/decoupage-organisation.md is brought up to
   date. Its project table listed 7 .csproj; the repo has 14
   (the four PostIt projects, the tests satellites, the cli
   tool). The table is extended, the ASCII diagram picks up
   the PostIt block, and an Outils et tests section lists
   the test / CLI satellites that were missing.

doc/README.md is updated to index the new postit.md. No code
changes in this commit, no behaviour change.
2026-07-09 21:54:06 +01:00
13d985e4e3 fixes the button access 2026-07-09 21:35:06 +01:00
d3664c5cdc postIt: scope list in SettingsPage, fix Settings DI re-registration
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.
2026-07-09 21:24:48 +01:00
375e6482a6 test(yavsc.org): cover the kid derivation in ComputeKid
Extract the kid calculation out of LoadSigningCredentialsInner
into a new internal static HostingExtensions.ComputeKid(string),
and cover it with five focused unit tests in
Yavsc.Org.Tests.ComputeKidTests.

The kid is the bit of signing-credential metadata that ties a
JWT to the right key in the JWKS. Without it, resource servers
(Yavsc.Blogs, Yavsc.Api) fail signature validation with IDX10500
'The signature key was not found', as fixed in 2c6d1157. That fix
inlined three lines of thumbprint-truncation logic at the top of
LoadSigningCredentialsInner, but left the calculation untested.
The tests in this commit pin its shape, value, stability, and
uniqueness, so a future refactor (e.g. switching from SHA-1 to
SHA-256, or moving to X509CertificateLoader for SYSLIB0057) has
to update them deliberately instead of silently changing the
JWKS key id.

Concretely:

  - InternalsVisibleTo("Yavsc.Org.Tests") in AssemblyInfo.cs
    gives the test project access to the new internal method
    without forcing LoadSigningCredentialsInner to leak
    further.
  - ComputeKid(string) is the single source of truth for the
    16-hex truncation; the production call site in
    LoadSigningCredentialsInner now reads
    'var kid = ComputeKid(certPath);'.
  - The inline comment block is updated to say SHA-1 (which is
    what X509Certificate2.GetCertHash() actually returns) instead
    of the previous SHA-256 claim. The behaviour is unchanged.
  - ComputeKid uses X509CertificateLoader.LoadCertificateFromFile
    rather than the obsolete 'new X509Certificate2(string)' ctor
    (SYSLIB0057); same on-disk behaviour, no obsolete warning.

Tests cover:
  - 16-char upper-case hex output matching the first 16 hex
    chars of the cert's GetCertHash();
  - stability across repeated reads of the same cert;
  - distinctness between two independently generated certs;
  - the SHA-1 size of the underlying thumbprint (20 bytes), so
    a future switch to SHA-256 forces a test update;
  - CryptographicException propagation for a missing cert file
    (Assert.ThrowsAny to stay portable across the Linux OpenSSL
    and Windows leaf exception types).
2026-07-09 20:28:58 +01:00
2c6d11577c Yavsc.Org: set KeyId on signing credentials
IdentityServer8 was emitting JWTs without a 'kid' header and
serving the JWKS without per-key identifiers, because
LoadSigningCredentialsInner constructed RsaSecurityKey /
ECDsaSecurityKey objects without an explicit KeyId. Resource
servers (Yavsc.Blogs, Yavsc.Api) cannot match a token to a key
in the JWKS without one, so every signature validation failed
with 'The signature key was not found' (Microsoft.IdentityModel
IDX10500).

Root cause: SigningCredentials were built directly from the
BC-parsed key parameters, bypassing the X509Certificate2 path
IdentityServer normally derives the kid from. The fix derives
a stable KeyId from the certificate's SHA-256 thumbprint
(truncated to 16 hex chars) and sets it on both SecurityKey
variants before constructing SigningCredentials.

The thumbprint-based kid is stable across process restarts as
long as the cert doesn't change, and changes naturally on
LetsEncrypt renewal (~90 days), which is the right behaviour:
old tokens age out, resource servers refresh their JWKS cache
to discover the new kid.

Production rollout: redeploy Yavsc.Org and re-login (or let
the refresh-token path rotate) so newly issued tokens carry
the kid. Pre-restart tokens will continue to be rejected with
IDX10500 until they expire or are refreshed.
2026-07-09 00:32:20 +01:00
6055117929 first, the blogs 2026-07-08 23:18:14 +01:00
0adc60d5ed simpler 2026-07-08 23:16:16 +01:00
2eadd6e1b7 better 2026-07-08 23:16:04 +01:00
b7873ebd7c GET posts [dev] 200 2026-07-08 22:46:46 +01:00
0aea6c0dbd PostIt: Sauver button on SettingsPage with dirty tracking
SettingsPage.axaml had TextBox / CheckBox TwoWay bindings to the
Settings singleton, but no Save button — user edits mutated the
in-memory instance and were lost on the next launch. This commit
addes the missing save path:

- Settings.Save() writes the current instance to
  ~/.config/PostIt/postit-settings.json (symmetrical to Load),
  with 0600 POSIX permissions matching TokenStore.Save.
- Settings.IsDirty ObservableProperty flips to true on every
  setter that flows through the four top-level
  [ObservableProperty] fields (DarkMode, BlogsApiUrl,
  BusinessApiUrl, plus the OnAuthenticationChanged partial for
  the Authentication sub-property). Sub-property edits
  (Authentication.Authority / ClientId / RedirectUri / Scopes)
  are caught by a PropertyChanged subscription wired up in
  OnAuthenticationChanged and re-wired on each Authentication
  reassignment.
- [RelayCommand(CanExecute = nameof(CanSave))] on Save itself
  emits the SaveCommand ICommand that the XAML binds to.
  OnIsDirtyChanged calls SaveCommand.NotifyCanExecuteChanged()
  so the button auto-enables / auto-disables. The Avalonia
  binding is 'SaveCommand' without a suffix — the source
  generator emits that property name from the Save method.
- ApplyJson resets IsDirty = false at the end so disk / embedded
  loads don't leave the page stuck in dirty state.
- SettingsPage.axaml: fixed the RowDefinition count (4 rows
  declared, 10 used — controls at rows 4..9 were rendering
  outside the grid), and added a Sauver button at row 10 bound
  to SaveCommand with IsEnabled driven by !IsDirty.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Tests: 45 / 45 passing.
2026-07-08 20:05:09 +01:00
e9df13a477 test(postit): pin blogs scope on bearer, fix post-refactor tests
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.
2026-07-08 19:36:40 +01:00
4a6609e2f1 PostIt: route Paramètres button to SettingsPage
The "Paramètres" button on SessionStatusBanner was wired to a stub
OpenSettingsCommand with a TODO. With the Settings model refactor
(VM consolidated to ViewModels/Settings.cs, SettingsViewModel.cs
dropped, App.axaml.cs registering Settings instead of the old VM),
the navigation is now plumbed end to end:

- SessionStatusViewModel gains an OpenSettingsRequested event
  alongside LogoutCompleted / LoginSucceeded, and the
  [RelayCommand] body just raises it. VM stays decoupled from
  NavigationPage and window lifetime, same pattern as the
  existing banner events.
- App.axaml.cs handles the event in the desktop branch: resolves
  SettingsPage (transient) and the canonical Settings singleton
  (the one we Load()'d at startup and bound via
  Settings.BindToServiceProvider) from DI, then PushAsync the
  page on top of the current NavRoot stack. Two-way bindings on
  SettingsPage mutate the singleton in place.

Build: dotnet build src/PostIt/PostIt/PostIt.csproj → 0 errors.
Existing CS8602 / NU1507 / CS8632 warnings unchanged.
2026-07-08 19:03:54 +01:00
0480e3e3e8 fix(blogs): drop debug MapGet("/identity") claim-dumper
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.
2026-07-07 22:13:29 +01:00
006e05a375 fix(blogs): register controller endpoints via MapControllers
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.
2026-07-07 21:48:02 +01:00
4dc1946c82 PostIt: drop unused Settings.folder + align ViewLocator nullability
Two 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.
2026-07-07 20:59:26 +01:00
9e272a8147 PostIt: drop LoginPage, hoist Login into session banner, drop AuthorId field
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.cs
2026-07-07 20:47:00 +01:00
c3f2408c4a test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
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).
2026-07-06 23:29:51 +01:00
6d222cf819 fix(blogs): accept JSON on POST /api/v1/blog (no file)
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.
2026-07-06 22:03:41 +01:00
9f5a1505e3 test(blogs): GET /api/v1/blog returns 200 with empty list
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).
2026-07-06 21:58:14 +01:00
8c38bab45a feat(tests): scaffold Yavsc.Blogs.Tests with BlogsWebServerFixture
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.
2026-07-06 21:49:53 +01:00
56 changed files with 2723 additions and 989 deletions

View file

@ -9,6 +9,8 @@
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
@ -21,6 +23,8 @@
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,16 @@
info:
name: Get Posts
type: http
seq: 1
http:
method: GET
url: https://jsonplaceholder.typicode.com/users
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: This request retrieves a list of users from the JSONPlaceholder API.

View file

@ -0,0 +1,15 @@
info:
name: Untitled
type: http
seq: 1
http:
method: GET
url: ""
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

15
contrib/bruno/blogs.yml Normal file
View file

@ -0,0 +1,15 @@
info:
name: blogs
type: http
seq: 1
http:
method: GET
url: "{{Blogs}}/api/v1/blog"
auth: inherit
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5

View file

@ -0,0 +1,6 @@
name: Development
variables:
- name: Blogs
value: https://localhost:5003
- name: Authority
value: https://localhost:5001

View file

@ -0,0 +1,6 @@
name: Production
variables:
- name: Authority
value: https://yavsc.pschneider.fr
- name: Blogs
value: https://blogs.pschneider.fr

View file

@ -0,0 +1,43 @@
opencollection: 1.0.0
info:
name: blogs
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
request:
auth:
type: oauth2
flow: authorization_code
authorizationUrl: "{{Authority}}/connect/authorize"
accessTokenUrl: "{{Authority}}/connect/token"
refreshTokenUrl: https://yavsc.pschneider.fr/connect/token
callbackUrl: "{{Authority}}"
credentials:
clientId: postit
placement: basic_auth_header
scope: openid blogs
pkce: {}
tokenConfig:
id: credentials
placement:
header: Bearer
source: access_token
settings:
autoFetchToken: true
autoRefreshToken: true
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git

View file

@ -15,6 +15,7 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme | | [architecture/dictionnaires-metier.md](architecture/dictionnaires-metier.md) | Dictionnaires métier, héritage en arbre, cycle de vie d'un terme |
| [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter | | [architecture/offres-frontmatter.md](architecture/offres-frontmatter.md) | Offre fournisseur, ClasseFormulaire, ClasseDevis, parsing frontmatter |
| [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh | | [architecture/postit-oidc.md](architecture/postit-oidc.md) | Client desktop PostIt, custom URI scheme, silent refresh |
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) | | [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
## Roadmap & design exploration ## Roadmap & design exploration

View file

@ -31,7 +31,19 @@
└────────────────┘ └────────────────┘
Clients externes : Clients externes :
- PostIt : client desktop Avalonia (cf. postit-oidc.md). - PostIt (Avalonia, code-base unique multi-cible) :
· PostIt — lib partagée (pages, VM, services)
· PostIt.Desktop — front-end Linux/Windows
· PostIt.Android — front-end APK
· PostIt.Browser — front-end WASM
Cf. postit.md et postit-oidc.md.
Outils et tests :
- cli — outillage CLI
- Yavsc.Tests.Shared — helpers de tests partagés
- Yavsc.Org.Tests — tests du front web
- Yavsc.Blogs.Tests — tests du backend blogs
- PostIt.Tests — tests du client PostIt
``` ```
## Par projet ## Par projet
@ -44,6 +56,14 @@ Clients externes :
| `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. | | `Yavsc.Api` | ASP.NET Web | API REST JSON principale consommée par les clients externes (PostIt, …). JwtBearer auth. |
| `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. | | `Yavsc.Blogs` | ASP.NET Web | **Backend API headless** dédié aux blogs (uniquement `*ApiController` + services + modèles — aucune vue Razor). Destiné à être déployé sur un sous-domaine en production, séparé du front web hébergé par `Yavsc.Org`. |
| `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. | | `Yavsc.Org.Tests` | Test (xUnit) | Tests d'isolation du front web (`Yavsc.Org`) — fakes, controller tests. |
| `Yavsc.Blogs.Tests`| Test (xUnit) | Tests d'isolation du backend blogs (`Yavsc.Blogs`). |
| `Yavsc.Tests.Shared` | Library | Helpers de tests partagés (fixtures, fakes, builders) entre les projets de tests. |
| `PostIt` | Library | Code-base partagée du client PostIt (Avalonia) : pages, ViewModels, services, `ViewLocator` custo. Multi-cible — produit PostIt.Desktop / PostIt.Android / PostIt.Browser. |
| `PostIt.Desktop` | Avalonia.Desktop | Front-end Desktop Linux/Windows : `Program.Main`, `Platform.CreateBrowser` (CustomSchemeBrowser), custom URI scheme `postit://`. |
| `PostIt.Android` | Avalonia.Android | Front-end Android : `MainActivity` SingleTask, Chrome Custom Tabs, scheme `android://postit-signin`. |
| `PostIt.Browser` | Avalonia.Browser | Front-end WASM : pas de process distinct, IBrowser N/A. |
| `PostIt.Tests` | Test (xUnit) | Tests du client PostIt : settings, scopes Bearer, OIDC stub (`OidcStubAuthority`). |
| `cli` | exe / tool | Outillage CLI (build, packaging, génération de clés). |
## Pourquoi ce découpage ## Pourquoi ce découpage

255
doc/architecture/postit.md Normal file
View file

@ -0,0 +1,255 @@
# PostIt — Topologie, navigation, DI
> **Récapitulatif** : PostIt est le client Avalonia du projet
> Yavsc. C'est un code-base unique (`src/PostIt/PostIt/PostIt.csproj`)
> **multi-cible** vers trois front-ends distincts
> (`PostIt.Desktop`, `Postit.Android`, `PostIt.Browser`). Cette
> fiche couvre la topologie des projets, le DI, le `ViewLocator`
> custo et la navigation — c'est-à-dire tout ce que la fiche
> [postit-oidc.md](postit-oidc.md) ne détaille pas déjà (l'OIDC,
> le flow d'auth, la persistance des tokens). Détail dans cette
> page, racine de l'architecture : [Architecture.md](../Architecture.md).
## Surface : un code-base, trois front-ends
```
┌────────────────────────┐
│ PostIt (lib) │
│ src/PostIt/PostIt/ │
│ Pages, ViewModels, │
│ Services, ViewLocator │
│ (aucun rendu natif) │
└──────┬───┬─────┬───────┘
│ │ │
┌───────────────┘ │ └────────────────┐
│ │ │
┌──────────▼────────┐ ┌────────▼─────────┐ ┌──────────▼────────┐
│ PostIt.Desktop │ │ PostIt.Android │ │ PostIt.Browser │
│ Avalonia.Desktop │ │ Avalonia.Android │ │ Avalonia.Browser │
│ Linux/Windows │ │ APK │ │ WASM │
│ + custom scheme │ │ + Chrome Custom │ │ (no native proc) │
│ postit:// │ │ Tabs │ │ │
│ + IBrowser custo │ │ + IBrowser custo │ │ │
└───────────────────┘ └──────────────────┘ └───────────────────┘
```
Le code partagé vit dans `PostIt/`. Chaque front-end est un
**projet Satellite SDK** Avalonia qui ne contient que le
`Program.Main`, le `Platform.CreateBrowser`, et les manifestes
spécifiques (IntentFilter Android, `app.manifest` Desktop).
Toute la logique (VM, services, navigation, settings, OIDC) est
dans le code-base partagé.
## ViewLocator custo
Le `ViewLocator` (cf. `src/PostIt/PostIt/ViewLocator.cs`) est un
`IDataTemplate` Avalonia **explicitement câblé sur le
`IServiceProvider`** :
```csharp
public Control Build(object? data) => data switch
{
MainPageViewModel => _services.GetRequiredService<MainPage>(),
Settings => _services.GetRequiredService<SettingsPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
};
public bool Match(object? data) => data is ViewModelBase;
```
**Pourquoi un custo, et pas le `ViewLocatorBase` par défaut
d'Avalonia.Mvvm ?** Pour deux raisons :
1. **Sortie du `Activator.CreateInstance`** — les pages
PostIt sont enregistrées dans le DI et peuvent avoir des
dépendances (par construction, aujourd'hui aucune, mais
l'extension future est ouverte). Le `ViewLocatorBase`
historique fait `new View()`, ce qui rend impossible
l'injection et complique les tests.
2. **Filtrage par `ViewModelBase`**`Match` n'accepte que les
types dérivés de `ViewModelBase`. Toute tentative d'afficher
un objet métier (par ex. un DTO de l'API Yavsc) tombe sur le
`TextBlock` "No view for X", pas sur un crash Avalonia.
Le `ViewLocator` est ajouté aux `DataTemplates` de l'app dans
`App.OnFrameworkInitializationCompleted` :
```csharp
DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider));
```
**Conséquence pratique** : pour qu'une nouvelle page soit
affichée par un `ContentControl` qui binde un ViewModel, il
faut *deux* enregistrements : la page en `AddTransient` (ou
`AddSingleton`) dans le DI, **et** une case dans le `switch`
de `ViewLocator.Build`. Si l'un manque, l'app affiche
"No view for X" sans crash.
## Composition root (`App.axaml.cs`)
`App.OnFrameworkInitializationCompleted` est le seul endroit où
le DI est construit. Ordre, dans cet ordre :
1. `new Settings()` + `settings.Load()` — lit
`~/.config/PostIt/postit-settings.json` (ou le fallback
embarqué dans `PostIt.dll`).
2. `new TokenStore(...)` + `new YavscApiClient(settings, tokenStore)`.
3. `new ServiceCollection()` + enregistrements en bloc.
4. `services.BuildServiceProvider()`.
5. `Settings.BindToServiceProvider(provider)` — pose le
singleton statique pour les helpers hors-DI
(`Settings.GetCurrent()`, `Settings.RequireCurrent()`).
6. `DataTemplates.Add(new ViewLocator(provider))`.
7. Branche `IClassicDesktopStyleApplicationLifetime` /
`ISingleViewApplicationLifetime` (Browser/Android).
### Enregistrements DI
| Service | Lifetime | Pourquoi |
|-------------------------------|------------|-------------------------------------------------------------------------------------------|
| `Settings` | **Singleton** | État partagé (`Loaded`, `IsDirty`, `Authentication`) — doit être unique. |
| `YavscApiClient` | Singleton | Porte le `TokenStore` et le cache de tokens ; un seul par process. |
| `BlogApiClient` | Singleton | Mapper stateless, partagé. |
| `SettingsPage` | **Singleton** | Une seule instance pour la vie de l'app : le `DataContext` est câblé une fois au boot, le push est idempotent (cf. section *Garde anti-empilement* ci-dessous). |
| `MainPage` / `HomePage` / `SignaturePage` | Transient | Résolution à la demande par le `ViewLocator`. |
| `MainPageViewModel` / `HomePageViewModel` / `SignaturePageViewModel` | Transient | VM reconstruites à chaque navigation ; pas d'état partagé à conserver. |
| `SessionStatusViewModel` + `SessionStatusBanner` | Singleton + Transient | Le VM est un singleton (survit à la navigation), le bandeau est transient (réinstancié quand la fenêtre le recrée). |
> **Invariant** : `Settings` est **uniquement** un singleton. Un
> `AddTransient<Settings>()` supplémentaire (qui réécrase le
> singleton dans le container) ferait que chaque push de
> `SettingsPage` crée une instance vide, casse les bindings
> Authority/ClientId, et perd toute édition. Si tu dois toucher
> à cette table, *ne pas* ajouter de registration pour
> `Settings` ailleurs que la ligne `AddSingleton(settings)`.
## Navigation
Le host de navigation est un `NavigationPage x:Name="NavRoot"`
posé sur `MainWindow.axaml`. La pile est gérée par les
événements du `SessionStatusViewModel` :
| Événement | Effet |
|---------------------------------|------------------------------------------------------------------------|
| `LoginSucceeded` | `PushAsync(MainPage)` au-dessus de `HomePage`. |
| `LogoutCompleted` | `PopToRootAsync()` (revient à `HomePage`). |
| `OpenSettingsRequested` | `PushAsync(SettingsPage)` au-dessus de la page courante. |
### Garde anti-empilement
`NavigationPage.PushAsync` n'est pas idempotent : pousser deux
fois la même instance l'empile deux fois, et l'utilisateur doit
taper **Retour** N fois pour sortir. Le handler
`OpenSettingsRequested` est gardé pour bloquer ce cas :
```csharp
var settingsPage = provider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return; // déjà au sommet, no-op silencieux
}
_ = w.NavRoot.PushAsync(settingsPage);
```
La comparaison est par référence, pas par type : on ne veut
empêcher qu'un push de *cette* instance particulière, pas
celui d'une éventuelle autre `SettingsPage` (il n'en existe
qu'une, mais l'invariant est plus clair comme ça). La garde
repose sur le fait que `SettingsPage` est un singleton ; si on
repassait en `Transient`, `ReferenceEquals` resterait correct
mais la pertinence de la garde s'évaporerait (chaque push
apporterait une nouvelle instance et l'anti-empilement
reposerait sur l'invariant « la même est déjà au sommet »,
qui ne tiendrait plus).
## ViewModels et invariants d'état
- `Settings` est un objet-modèle exposé comme `DataContext`
des pages. Il n'hérite pas de `ViewModelBase` (c'est un
POCO `[ObservableProperty]`-généré par
`CommunityToolkit.Mvvm`). Le fait qu'il soit utilisé comme
DataContext est un raccourci de composition acceptable ici,
pas un pattern à généraliser.
- `SessionStatusViewModel` est le seul VM avec une durée de vie
**process-entière** (singleton). Il survit à toutes les
navigations, expose `HasValidSession` en continu, et porte
les trois événements qui pilotent la navigation
(`LoginSucceeded`, `LogoutCompleted`,
`OpenSettingsRequested`).
- `MainPageViewModel` / `HomePageViewModel` /
`SignaturePageViewModel` sont `Transient` — une nouvelle
instance est créée à chaque push, l'ancienne est libérée
quand la page est dépilée. Pas d'état partagé entre
occurrences ; pour passer une donnée d'une page à l'autre,
on passe par un singleton (souvent `YavscApiClient` ou
`Settings`).
## Bindings XAML : conventions de nommage
Pour les `[RelayCommand]` (cf. `CommunityToolkit.Mvvm`), le
binding XAML reprend **le nom exact de la méthode, sans
suffixe** :
| Méthode C# | Binding XAML |
|-----------------------|-----------------------------|
| `Save()` | `{Binding Save}` |
| `SaveAsync()` | `{Binding SaveAsync}` |
| `LoginCommand()` | `{Binding LoginCommand}` (nom littéral, *pas* de suffixe ajouté) |
| `Clear()` | `{Binding Clear}` |
| `CaptureAsync()` | `{Binding CaptureAsync}` |
**JAMAIS** `SaveCommand`, `SaveCmd`, `DoSave`, etc. Le source
generator `[RelayCommand]` émet une propriété `ICommand` du
même nom que la méthode. Un binding qui pointe vers une
propriété inexistante casse l'app au moment du câblage (le
bouton ne se câble pas, et selon la version ça peut faire
planter l'init de la page).
Référence canonique : `AGENTS.md`, section
"Avalonia + CommunityToolkit.Mvvm : conventions de binding
pour `[RelayCommand]`".
## Pages et leurs rôles
| Page | DataContext | Rôle |
|----------------------------|--------------------------|-----------------------------------------------------------------------|
| `MainWindow` | `HomePageViewModel` (initial) | Host de la `NavigationPage`. |
| `SessionStatusBanner` | `SessionStatusViewModel` | Bandeau persistant en haut de la fenêtre, visible sur toutes les pages. Boutons Login / Logout / Paramètres. |
| `HomePage` | `HomePageViewModel` | Page d'accueil publique. |
| `MainPage` | `MainPageViewModel` | Éditeur de post de blog (après login). |
| `SignaturePage` | `SignaturePageViewModel` | Capture de signature (estimateur). |
| `SettingsPage` | `Settings` | Édition de Authority / ClientId / Scopes / URLs API / Dark mode. Sauver via `Save` (RelayCommand). |
## Conséquences pratiques
- **Ajouter une page** : créer la View + le ViewModel +
enregistrer les deux dans le DI **et** dans le `switch` de
`ViewLocator.Build`. Oublier le `ViewLocator` est silencieux
(juste un TextBlock "No view for X"), pas une exception.
- **Ajouter un événement global de navigation** (par ex.
"Push après payment success") : passer par un événement sur
un VM singleton (cf. `SessionStatusViewModel.OpenSettingsRequested`),
pas par une référence à `MainWindow` depuis le VM. Garder
les VMs découplés du `IClassicDesktopStyleApplicationLifetime`.
- **Modifier l'OIDC** : la fiche à lire est
[postit-oidc.md](postit-oidc.md), pas celle-ci. Cette fiche
ne ré-explique ni le flow, ni le pipe, ni le custom scheme.
- **Modifier les `Settings`** : ne pas casser le singleton
(cf. invariant ci-dessus). Toute propriété présentationnelle
ajoutée (par ex. `ScopeListText`) doit porter `[JsonIgnore]`
pour ne pas polluer le format sur disque.
## Voir aussi
- [Architecture.md](../Architecture.md) — racine.
- [postit-oidc.md](postit-oidc.md) — flow OIDC, custom scheme,
silent refresh, persistance des tokens.
- [decoupage-organisation.md](decoupage-organisation.md) —
place de `PostIt` dans le découpage global des projets
.NET du repo.

View file

@ -0,0 +1,288 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using PostIt.Services;
using Xunit;
namespace PostIt.Tests;
/// <summary>
/// Diagnostic coverage for the 401 we're seeing in production when
/// PostIt talks to <c>Yavsc.Blogs</c>. The hypothesis this file
/// isolates: "the access token sent on the wire is missing the
/// <c>blogs</c> scope that <c>Yavsc.Blogs</c>'s <c>BlogScope</c>
/// policy requires". The policy lives in
/// <c>Yavsc.Blogs/Program.cs</c> as
/// <c>RequireClaim(JwtClaimTypes.Scope, "blogs")</c>.
///
/// <para>
/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
/// any network listener. The test fakes a single
/// <see cref="HttpMessageHandler"/> that captures the outbound
/// request, deserialises the bearer JWT, and asserts the
/// <c>scope</c> claim contains the segment the policy needs. This
/// pins the client side of the contract so a future regression in
/// <see cref="YavscApiClient"/> or <see cref="Settings"/> (e.g. a
/// silently dropped scope, a wrong merge order, a scope string
/// that no longer matches the server policy) trips the test before
/// it reaches production.
/// </para>
/// </summary>
public class BearerScopeTests
{
/// <summary>
/// Hard-coded <c>blogs</c> scope string. Mirrors the value in
/// <c>Yavsc.Blogs/Program.cs</c>'s <c>BlogScope</c> policy; if
/// the server ever moves to <c>"blog.read"</c> or similar this
/// constant should be updated to match.
/// </summary>
private const string RequiredScope = "blogs";
[Fact]
public async Task GetPostsAsync_sends_bearer_with_blogs_scope_in_jwt()
{
// Build the exact scope list a user would have in
// postit-settings.json. MergeScopes (called inside
// YavscApiClient when issuing the authorize request) would
// have appended "openid profile offline_access", so the
// access token in real life carries all of them. The test
// pins that the scope the *server* needs survived the
// round trip from settings.json to the access_token.
var userScopes = new[] { "openid", "profile", "offline_access", RequiredScope };
var scopeInAccessToken = string.Join(' ', userScopes);
// Mint a fake access token whose only payload claim is
// "scope". No signature: the client never verifies, and the
// production server doesn't see this token (we mock the
// HttpMessageHandler, so the message never leaves the
// process).
var accessToken = MintUnsignedJwt(scopeInAccessToken);
var settings = new PostIt.ViewModels.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://example.invalid",
ClientId = "postit-tests",
Scopes = userScopes,
RedirectUri = "postit://callback",
},
BusinessApiUrl = "https://example.invalid/api/v1/",
};
var tokensPath = Path.Combine(
Path.GetTempPath(), $"postit-bearer-scope-{Guid.NewGuid():N}.json");
try
{
// Pre-seed the token store so YavscApiClient believes
// it has a valid session and CallAsync does not refuse
// to send.
var store = new TokenStore(tokensPath);
store.Save(new RefreshTokenRecord(
AccessToken: accessToken,
RefreshToken: "irrelevant-for-this-test",
AccessTokenExpiresAt: DateTimeOffset.UtcNow.AddHours(1),
IdToken: null));
// CapturingHttpHandler is the assertion point. It
// records the first request's Authorization header and
// returns 200 with an empty array (BlogApiClient
// deserialises to List<BlogPost>).
var captured = new CapturingHttpHandler();
var client = new YavscApiClient(
settings,
store,
// Bypass OidcClient construction (it would try to
// resolve an Authority we don't have a real IdP
// for). The handler we inject below is what the
// bearer attaches the token to; refresh paths are
// not exercised in this test.
oidc: null!);
// YavscApiClient builds its own HttpClient around a
// BearerTokenHandler(new HttpClientHandler()) in its
// constructor; the handler is not exposed for
// replacement. The seam we use: CallAsync is virtual,
// so a subclass that talks to a caller-supplied
// HttpMessageHandler lets us assert on the outbound
// request without standing up any server.
var subClient = new TestableYavscApiClient(
settings, store, captured, accessToken);
// Resolve a BlogApiClient on top. We don't need real
// posts; we just need the outbound HTTP request to be
// the one we capture.
var blog = new BlogApiClient(subClient);
await blog.GetPostsAsync(ct: TestContext.Current.CancellationToken);
// The test only makes sense if we did capture
// something. If we got here with an empty capture, the
// BlogApiClient chose a non-HTTP path and this whole
// setup is wrong.
Assert.NotNull(captured.Authorization);
Assert.StartsWith("Bearer ", captured.Authorization);
var jwt = captured.Authorization.Substring("Bearer ".Length).Trim();
var scopes = ExtractScopes(jwt);
Assert.Contains(RequiredScope, scopes);
}
finally
{
if (File.Exists(tokensPath)) File.Delete(tokensPath);
}
}
// --- helpers -------------------------------------------------------
/// <summary>
/// Build an unsigned JWT carrying a single <c>scope</c> claim.
/// Mirrors the read-only fallback in
/// <see cref="YavscApiClient.ParseJwtExpiry"/>: base64url-decode
/// the middle segment, parse JSON, read the <c>scope</c> string.
/// The header and signature are placeholders — nobody in the
/// test path verifies the signature.
/// </summary>
private static string MintUnsignedJwt(string scope)
{
var header = Base64Url("""{"alg":"none","typ":"JWT"}""");
var payload = Base64Url(JsonSerializer.Serialize(new
{
sub = "test-user",
iss = "https://example.invalid",
aud = "postit",
exp = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(),
iat = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
scope,
}));
return $"{header}.{payload}.";
}
private static string Base64Url(string s)
{
var bytes = Encoding.UTF8.GetBytes(s);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
/// <summary>
/// Pull the <c>scope</c> claim out of a (possibly unsigned) JWT
/// and split on whitespace, the canonical encoding per RFC 8693
/// §4.2 and OpenID Connect Core 1.0 §5.1.
/// </summary>
private static IReadOnlyCollection<string> ExtractScopes(string jwt)
{
var parts = jwt.Split('.');
Assert.True(parts.Length >= 2, "JWT must have a payload segment");
var payload = parts[1].Replace('-', '+').Replace('_', '/');
switch (payload.Length % 4)
{
case 2: payload += "=="; break;
case 3: payload += "="; break;
}
using var doc = JsonDocument.Parse(Convert.FromBase64String(payload));
if (!doc.RootElement.TryGetProperty("scope", out var scopeEl))
{
return Array.Empty<string>();
}
var raw = scopeEl.GetString() ?? string.Empty;
return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
}
/// <summary>
/// Minimal <see cref="HttpMessageHandler"/> that records the
/// first request's <c>Authorization</c> header and replies 200
/// with an empty JSON array. Anything beyond the first request
/// is a regression in the test setup, not the production code
/// path under test.
/// </summary>
private sealed class CapturingHttpHandler : HttpMessageHandler
{
public string? Authorization { get; private set; }
public Uri? RequestUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Authorization = request.Headers.Authorization?.ToString();
RequestUri = request.RequestUri;
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("[]", Encoding.UTF8, "application/json"),
};
return Task.FromResult(response);
}
}
/// <summary>
/// Subclass of <see cref="YavscApiClient"/> that routes HTTP
/// traffic through a caller-supplied
/// <see cref="HttpMessageHandler"/>. The base ctor wires
/// <c>Http</c> as <c>new HttpClient(BearerTokenHandler(...))</c>;
/// we don't replace that — we override the public call seam
/// <see cref="YavscApiClient.CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>
/// (declared <c>virtual</c>) and talk to our own HttpClient
/// from there. The <c>EnsureFreshToken</c> / 401-retry path
/// is intentionally not exercised here — that lives in
/// <c>YavscApiClientTests</c>; isolating the bearer
/// attachment is the whole point of this test.
/// </summary>
private sealed class TestableYavscApiClient : YavscApiClient
{
private readonly HttpClient _http;
private readonly string _accessToken;
public TestableYavscApiClient(
PostIt.ViewModels.Settings settings,
TokenStore store,
HttpMessageHandler handler,
string accessToken)
: base(settings, store, oidc: null!)
{
_http = new HttpClient(handler, disposeHandler: false);
_accessToken = accessToken;
}
public override Task<T> CallAsync<T>(
HttpMethod method, string path, object? body = null,
CancellationToken ct = default)
{
// Reproduce just enough of the production request
// shape: a real HttpRequestMessage with the bearer
// attached, so the assertion in the test is faithful.
// We skip the EnsureFreshToken/401-retry machinery on
// purpose — that path is already covered by
// YavscApiClientTests, and isolating the bearer
// attachment is exactly what this test exists for.
//
// The base YavscApiClient relies on HttpClient.BaseAddress
// being set by BlogApiClient's ctor; in this test our
// private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode();
using var stream = resp.Content.ReadAsStream();
var dto = JsonSerializer.Deserialize<T>(stream,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return Task.FromResult(dto!);
}
}
}

View file

@ -0,0 +1,65 @@
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Tests;
/// <summary>Per-call ledger shared between the test and the
/// recording fake, so the assertion can inspect what the VM
/// actually sent on the wire without coupling to the fake's
/// internals.</summary>
internal sealed class CallRecorder
{
public (HttpMethod method, string path, object? body) FirstCall =>
Calls[0];
public List<(HttpMethod method, string path, object? body)> Calls { get; } = new();
}
/// <summary>Test fake that records every CallAsync invocation
/// and answers them with a canned sequence: the first call gets
/// a server-issued BlogPost (Id=42), the second call gets a
/// single-element list containing that post. Used by the ViewModel
/// tests and the headless UI test to capture exactly what the
/// Save button posts to the server.</summary>
internal sealed class RecordingYavscApiClient : YavscApiClient
{
private readonly CallRecorder _recorder;
public RecordingYavscApiClient(CallRecorder recorder)
: base(
new Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://stub.invalid",
ClientId = "stub",
Scopes = new[] { "openid" },
},
},
new TokenStore(System.IO.Path.GetTempFileName()))
{
_recorder = recorder;
}
public override Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
_recorder.Calls.Add((method, path, body));
// BlogPost? boxes to BlogPost at runtime, so we test the
// non-nullable type — typeof(BlogPost?) is a C# error
// (CS8639: "typeof cannot be used on a nullable reference
// type").
if (typeof(T) == typeof(BlogPost))
return Task.FromResult((T)(object)new BlogPost
{
Id = 42,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
});
if (typeof(T) == typeof(List<BlogPost>))
return Task.FromResult((T)(object)new List<BlogPost>
{
new() { Id = 42, Title = "Mon premier billet" }
});
return Task.FromResult(default(T)!);
}
}

View file

@ -1,257 +0,0 @@
using System;
using System.Threading.Tasks;
using PostIt.ViewModels;
using Xunit;
namespace PostIt.Tests;
public class LoginPageViewModelTests
{
[Fact]
public async Task LoginAsync_acquires_access_token_from_stubbed_yavsc_authority()
{
// Arrange: spin up a stub OIDC authority and a fake browser that
// short-circuits the system browser. The authority signs its
// access_token with RS256; the fake browser captures the redirect
// URI so the authority can complete the token exchange.
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer,
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
// Act
await vm.LoginAsync();
// Assert: the ViewModel surfaced a token, not an error.
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login did not produce a token. StatusMessage={vm.StatusMessage ?? "<null>"}");
Assert.False(
vm.StatusMessage?.StartsWith("Error") == true,
$"Login reported error: {vm.StatusMessage}");
}
[Fact]
public async Task LoginAsync_refuses_to_call_OidcClient_when_Authority_is_empty()
{
// Regression: when no user settings file exists and the embedded
// default somehow fails to load (e.g. resource stripped at publish
// time), the ViewModel must NOT hand a blank Authority to
// OidcClient — IdentityModel would build a bogus authorize URL
// like "http://127.0.0.1:1/" which the browser rejects with a
// confusing error. Surface a clear, actionable message instead.
//
// SettingsLoadOverride is set to a no-op so the test fixture's
// pre-loaded Settings object survives the call to LoginAsync.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "",
ClientId = "postit-tests",
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" },
};
var browserInvoked = false;
var vm = new LoginPageViewModel(settings, () =>
{
browserInvoked = true;
return null;
})
{
// Skip the disk / embedded read so the Authority stays empty.
SettingsLoadOverride = () => System.Threading.Tasks.Task.CompletedTask,
};
await vm.LoginAsync();
Assert.False(
browserInvoked,
"Browser factory was invoked even though Authority was empty.");
Assert.NotNull(vm.StatusMessage);
Assert.Contains("Configuration manquante", vm.StatusMessage);
Assert.Contains("postit-settings.json", vm.StatusMessage);
Assert.True(string.IsNullOrEmpty(vm.AccessToken));
}
[Fact]
public async Task LoginAsync_works_when_authority_has_trailing_slash()
{
// Regression: with Authority ending in "/" (the production
// postit-settings.json shape for https://yavsc.pschneider.fr/),
// the discovery URL OidcClient computes must NOT contain a
// double slash before /.well-known/openid-configuration. The
// stub advertises itself without the trailing slash; OidcClient
// must bridge.
using var authority = await OIDCStubAuthority.StartAsync();
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = authority.Issuer + "/",
ClientId = "postit-tests"
},
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, browser.CreateBrowser);
await vm.LoginAsync();
Assert.True(
!string.IsNullOrEmpty(vm.AccessToken),
$"Login with trailing slash failed. StatusMessage={vm.StatusMessage ?? "<null>"}");
}
[Fact]
public void RegisterUrl_and_ForgotPasswordUrl_are_derived_from_authority()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings);
// Trailing slash on Authority is normalised away.
Assert.Equal(
"https://yavsc.example.com/Account/Register",
vm.RegisterUrl);
Assert.Equal(
"https://yavsc.example.com/Account/ForgotPassword",
vm.ForgotPasswordUrl);
Assert.True(vm.HasRegisterUrl);
Assert.True(vm.HasForgotPasswordUrl);
}
[Fact]
public void RegisterUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.RegisterUrl);
Assert.Equal(string.Empty, vm.ForgotPasswordUrl);
Assert.False(vm.HasRegisterUrl);
Assert.False(vm.HasForgotPasswordUrl);
}
[Fact]
public void ConfigMissing_is_true_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.True(vm.ConfigMissing);
Assert.Contains("~/.config/PostIt/postit-settings.json", vm.ConfigMissingMessage);
}
[Fact]
public void ConfigMissing_is_false_when_authority_is_set()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
}
};
var vm = new LoginPageViewModel(settings);
Assert.False(vm.ConfigMissing);
}
[Theory]
[InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")]
[InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")]
public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected)
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings { Authority = authority }
};
var vm = new LoginPageViewModel(settings);
Assert.Equal(expected, vm.DiscoveryUrl);
// ExternalUrl is the slash-normalised form of Authority.
Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl);
}
[Fact]
public void DiscoveryUrl_is_empty_when_authority_is_unset()
{
var vm = new LoginPageViewModel(new PostIt.Settings());
Assert.Equal(string.Empty, vm.DiscoveryUrl);
}
[Fact]
public async Task LoginAsync_failure_message_includes_discovery_url()
{
// Arrange: settings point at an unreachable authority; the test
// browser throws synchronously to guarantee the catch branch runs.
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://does-not-exist.invalid/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom"));
// Act
await vm.LoginAsync();
// Assert: the surfaced error mentions the canonical discovery URL,
// so it can be copy-pasted into a browser to diagnose reachability.
Assert.NotNull(vm.StatusMessage);
Assert.StartsWith("Error:", vm.StatusMessage);
Assert.Contains(
"https://does-not-exist.invalid/.well-known/openid-configuration",
vm.StatusMessage);
}
[Fact]
public async Task LoginAsync_reports_discovery_url_when_no_browser_available()
{
var settings = new PostIt.Settings
{
Authentication = new AuthenticationSettings
{
Authority = "https://yavsc.example.com/",
ClientId = "postit-tests"
},
RedirectUri = "http://127.0.0.1:7890/",
Scopes = new[] { "openid" }
};
var vm = new LoginPageViewModel(settings, () => null);
await vm.LoginAsync();
Assert.Contains(
"https://yavsc.example.com/.well-known/openid-configuration",
vm.StatusMessage);
}
}

View file

@ -0,0 +1,89 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.VisualTree;
using PostIt.Models;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// Headless UI tests for the "Save" flow in <see cref="MainPage"/>.
/// The pattern is the one <c>SessionStatusBannerTests</c>
/// established: <c>[AvaloniaFact]</c>, a <see cref="Window"/>
/// hosting the page (via a <see cref="Frame"/> because
/// <c>MainPage</c> is a <c>ContentPage</c>), then drive the
/// controls through their public surface and assert on what
/// <see cref="RecordingYavscApiClient"/> saw go on the wire.
///
/// <para>The bug we are pinning: the title <c>TextBox</c> is
/// currently <c>{Binding SelectedPost.Title, Mode=TwoWay}</c>.
/// When <c>SelectedPost is null</c> (i.e. the user has not yet
/// clicked an item in the posts list — which is the only state
/// in which a brand-new post can be created), the binding has
/// no target and the user's keystrokes are silently dropped.
/// Clicking "Save" then routes to the VM branch
/// <c>if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }</c>
/// which the controller rejects with 400 "The Title field is
/// required." This test fails on that branch today and will
/// pass once the VM owns a dedicated <c>Title</c>/<c>Article</c>
/// buffer that the XAML binds to and the Save command consumes.</para>
/// </summary>
public class MainPageSaveTests
{
[AvaloniaFact]
public async Task Typing_a_title_then_clicking_Save_sends_that_title_in_the_post_body()
{
// Arrange: VM with a recording API client, mounted in a
// headless window via a Frame (MainPage is a ContentPage,
// not a Control, so it needs a navigation host).
var recorder = new CallRecorder();
var api = new RecordingYavscApiClient(recorder);
var blog = new BlogApiClient(api);
var viewModel = new MainPageViewModel(blog);
var page = new MainPage { DataContext = viewModel };
// MainPage is a ContentPage (a Page, not a Control), so it
// must be hosted in a navigation surface. The production
// MainWindow.axaml uses NavigationPage, and the API is the
// same one App.axaml.cs drives at boot (PushAsync, fire-
// and-forget in prod because the page is the top of the
// stack immediately).
var nav = new NavigationPage();
_ = nav.PushAsync(page);
var window = new Window { Content = nav };
window.Show();
// Act: type a title into the editor's TextBox without
// first selecting a post in the list — the only state in
// which a new post can be created. Then click Save.
var titleBox = window.GetVisualDescendants()
.OfType<TextBox>()
.First(t => t.PlaceholderText == "Title");
const string typed = "Mon premier billet";
titleBox.Text = typed;
var saveButton = window.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Save");
saveButton.Command!.Execute(null);
// The Save command is async (RelayCommand over Task) but
// ExecuteAsync would await; the sync Execute enqueues the
// task on the dispatcher. Give the dispatcher a chance to
// run so the awaited CallAsync has actually fired before
// we inspect the recorder.
await Task.Delay(200);
// Assert: the first POST to "blog" carried a BlogPost
// whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blog", path);
var sent = Assert.IsType<BlogPost>(body);
Assert.Equal(typed, sent.Title);
}
}

View file

@ -60,11 +60,11 @@ public class PostItViewModelTests
public ThrowingYavscApiClient() : base( public ThrowingYavscApiClient() : base(
new Settings new Settings
{ {
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://stub.invalid", Authority = "https://stub.invalid",
ClientId = "stub", ClientId = "stub",
Scopes = new[] { "openid" },
}, },
}, },
new TokenStore(System.IO.Path.GetTempFileName())) new TokenStore(System.IO.Path.GetTempFileName()))
@ -81,11 +81,11 @@ public class PostItViewModelTests
: base( : base(
new Settings new Settings
{ {
Scopes = new[] { "openid" },
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://stub.invalid", Authority = "https://stub.invalid",
ClientId = "stub", ClientId = "stub",
Scopes = new[] { "openid" },
}, },
}, },
new TokenStore(System.IO.Path.GetTempFileName())) new TokenStore(System.IO.Path.GetTempFileName()))

View file

@ -0,0 +1,125 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.VisualTree;
using PostIt.ViewModels;
using PostIt.Views;
namespace PostIt.Tests;
/// <summary>
/// UI tests for <see cref="SessionStatusBanner"/>. Mounted inside
/// a real <see cref="MainWindow"/> via the headless Avalonia
/// platform declared in <c>TestApp.cs</c>.
///
/// <para>The pattern is the one that <c>UnitTest1.MainPage_Should_Load</c>
/// established: a test attribute <c>[AvaloniaFact]</c> (from
/// <c>Avalonia.Headless.XUnit</c>) instead of plain <c>[Fact]</c>,
/// <c>new MainWindow()</c>, <c>window.Show()</c>. The AvaloniaFact
/// attribute schedules the test body inside a dispatcher, which
/// is the precondition for the headless Window's
/// <c>PlatformManager.CreateWindow()</c> to find a registered
/// service. A plain <c>[Fact]</c> test that calls
/// <c>new Window().Show()</c> throws because the harness has not
/// been initialised for that thread.</para>
///
/// <para>The session banner's <c>DataContext</c> is not wired in
/// these tests: <c>App.OnFrameworkInitializationCompleted</c> is
/// not called in a unit test, so we set the DataContext on the
/// banner directly. The production code path is exercised
/// end-to-end by the manual launch, not here.</para>
/// </summary>
public class SessionStatusBannerTests
{
[AvaloniaFact]
public void Banner_renders_three_buttons_in_the_visual_tree()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var buttons = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.ToList();
// Three buttons, named by their content text: Se
// déconnecter, Se connecter, Paramètres. If any one is
// missing, the user has no way to trigger the
// corresponding navigation event.
Assert.Equal(3, buttons.Count);
Assert.Contains(buttons, b => b.Content as string == "Se déconnecter");
Assert.Contains(buttons, b => b.Content as string == "Se connecter");
Assert.Contains(buttons, b => b.Content as string == "Paramètres");
}
[AvaloniaFact]
public void Banner_login_button_is_visible_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.True(vm.IsLoggedOut); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
var login = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se connecter");
// The XAML binds IsVisible to IsLoggedOut. After Show,
// the binding has been evaluated.
Assert.True(login.IsVisible);
}
[AvaloniaFact]
public void Banner_logout_button_is_hidden_when_logged_out()
{
var window = new MainWindow();
var vm = new SessionStatusViewModel();
Assert.False(vm.IsLoggedIn); // VM default
window.SessionBanner.DataContext = vm;
window.Show();
var logout = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Se déconnecter");
Assert.False(logout.IsVisible);
}
[AvaloniaFact]
public void Banner_settings_button_is_visible_regardless_of_session()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var settings = window.SessionBanner.GetVisualDescendants()
.OfType<Button>()
.Single(b => b.Content as string == "Paramètres");
// Paramètres is the only button with no IsVisible
// binding — always shown. The user's only path to the
// settings page goes through this button.
Assert.True(settings.IsVisible);
}
[AvaloniaFact]
public void Banner_session_label_reflects_DataContext()
{
var window = new MainWindow();
window.SessionBanner.DataContext = new SessionStatusViewModel();
window.Show();
var label = window.SessionBanner.GetVisualDescendants()
.OfType<TextBlock>()
.First(t => t.Text == "Déconnecté" || t.Text == "Connecté");
// Default SessionLabel is "Déconnecté" until Refresh()
// is called with a valid session. This pins the default
// so a future refactor that breaks the initial value
// (e.g. by removing the field initialiser) is caught.
Assert.Equal("Déconnecté", label.Text);
}
}

View file

@ -27,7 +27,7 @@ public class SettingsLoadTests
return; // nothing to assert: user file wins. return; // nothing to assert: user file wins.
} }
var settings = new PostIt.Settings(); var settings = new PostIt.ViewModels.Settings();
settings.Load(); settings.Load();
// The bundled postit-settings.json points at yavsc.pschneider.fr. // The bundled postit-settings.json points at yavsc.pschneider.fr.
@ -48,7 +48,7 @@ public class SettingsLoadTests
[Fact] [Fact]
public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state() public async Task Concurrent_load_and_mutate_does_not_throw_or_corrupt_state()
{ {
var settings = new PostIt.Settings(); var settings = new PostIt.ViewModels.Settings();
// First load pre-populates Authentication.Authority so the // First load pre-populates Authentication.Authority so the
// early-return path in Load() runs (we don't want file I/O // early-return path in Load() runs (we don't want file I/O
@ -58,9 +58,9 @@ public class SettingsLoadTests
settings.Authentication = new AuthenticationSettings settings.Authentication = new AuthenticationSettings
{ {
Authority = "https://example.test/", Authority = "https://example.test/",
ClientId = "postit-tests" ClientId = "postit-tests",
Scopes = new[] { "openid" },
}; };
settings.Scopes = new[] { "openid" };
// Load() takes the early-return path because Authority is // Load() takes the early-return path because Authority is
// already populated; flips Loaded=true under the gate. // already populated; flips Loaded=true under the gate.
settings.Load(); settings.Load();
@ -90,10 +90,10 @@ public class SettingsLoadTests
{ {
bool flip = ((workerId + i) & 1) == 0; bool flip = ((workerId + i) & 1) == 0;
settings.DarkMode = flip; settings.DarkMode = flip;
settings.RedirectUri = flip settings.Authentication.RedirectUri =
? PostIt.Settings.DefaultDesktopRedirectUri global::AuthenticationSettings.DefaultDesktopRedirectUri;
: PostIt.Settings.DefaultLoopbackRedirectUri;
settings.ApiUrl = flip settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/" ? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/"; : "https://b.example.test/api/v1/";
@ -102,7 +102,7 @@ public class SettingsLoadTests
// invariants that the gate protects. // invariants that the gate protects.
Assert.True(settings.Loaded); Assert.True(settings.Loaded);
Assert.NotNull(settings.Authentication); Assert.NotNull(settings.Authentication);
Assert.NotNull(settings.Scopes); Assert.NotNull(settings.Authentication.Scopes);
} }
} }
catch (Exception ex) catch (Exception ex)
@ -131,7 +131,7 @@ public class SettingsLoadTests
[Fact] [Fact]
public void Load_is_idempotent_under_concurrent_calls() public void Load_is_idempotent_under_concurrent_calls()
{ {
var settings = new PostIt.Settings var settings = new PostIt.ViewModels.Settings
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {

View file

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels;
using Xunit; using Xunit;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -61,6 +62,12 @@ public class YavscApiClientTests
// Reload — YavscApiClient constructor reads the store. // Reload — YavscApiClient constructor reads the store.
var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath)); var reloaded = new YavscApiClient(settings, new TokenStore(tokensPath));
// Same BaseAddress dance as LoginAndPersistAsync: a fresh
// YavscApiClient starts with no BaseAddress, and the test
// calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>( var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken); HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -111,16 +118,16 @@ public class YavscApiClientTests
[Fact] [Fact]
public async Task CallAsync_throws_when_no_token_and_no_interactive_login() public async Task CallAsync_throws_when_no_token_and_no_interactive_login()
{ {
var settings = new PostIt.Settings var settings = new Settings
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = "https://127.0.0.1:5001", Authority = "https://127.0.0.1:5001",
ClientId = "postit-tests", ClientId = "postit-tests",
RedirectUri = "postit://callback",
Scopes = new[] { "openid" },
}, },
RedirectUri = "postit://callback", BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
Scopes = new[] { "openid" },
ApiUrl = "https://127.0.0.1:5003/api/v1",
}; };
var client = new YavscApiClient(settings, new TokenStore(Path.Combine( var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -155,24 +162,30 @@ public class YavscApiClientTests
// --- helpers -------------------------------------------------------- // --- helpers --------------------------------------------------------
private static PostIt.Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new() private static Settings BuildSettings(OIDCStubAuthority authority, string apiBaseUrl) => new()
{ {
Authentication = new AuthenticationSettings Authentication = new AuthenticationSettings
{ {
Authority = authority.Issuer, Authority = authority.Issuer,
ClientId = "postit-tests", ClientId = "postit-tests",
RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" }
}, },
RedirectUri = authority.LoopbackRedirectUri, BusinessApiUrl = apiBaseUrl
Scopes = new[] { "openid", "profile", "blog" },
ApiUrl = apiBaseUrl,
}; };
private static async Task<YavscApiClient> LoginAndPersistAsync( private static async Task<YavscApiClient> LoginAndPersistAsync(
PostIt.Settings settings, OIDCStubAuthority authority, string tokensPath) Settings settings, OIDCStubAuthority authority, string tokensPath)
{ {
var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri); var browser = new FakeAuthorizingBrowser(authority.LoopbackRedirectUri);
var client = new YavscApiClient(settings, new TokenStore(tokensPath)); var client = new YavscApiClient(settings, new TokenStore(tokensPath));
// The two integration tests that call CallAsync("posts", ...)
// directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
// Force the API client to use the test browser by routing the // Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper. // LoginInteractiveAsync call through a small wrapper.
await LoginWithBrowserAsync(client, browser.CreateBrowser()); await LoginWithBrowserAsync(client, browser.CreateBrowser());

View file

@ -12,6 +12,7 @@
<PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.2" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Material.Avalonia" Version="3.17.0" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.8.0" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />

View file

@ -5,10 +5,10 @@ namespace PostIt.Android;
/// <summary> /// <summary>
/// One-shot platform bootstrap. Called from /// One-shot platform bootstrap. Called from
/// <see cref="MainActivity.OnCreate"/> so that the shared /// <see cref="MainActivity.OnCreate"/> so that the shared OIDC login
/// <c>LoginPageViewModel</c> sees the Android-specific redirect URI and a /// path sees the Android-specific redirect URI and a working
/// working <c>IBrowser</c> (Chrome Custom Tabs) without referencing /// <c>IBrowser</c> (Chrome Custom Tabs) without referencing Android
/// Android APIs from the shared library. /// APIs from the shared library.
/// </summary> /// </summary>
internal static class PlatformBootstrap internal static class PlatformBootstrap
{ {

View file

@ -5,8 +5,8 @@ namespace PostIt.Desktop;
/// <summary> /// <summary>
/// One-shot platform bootstrap. Called from <c>Program.Main</c> so that /// One-shot platform bootstrap. Called from <c>Program.Main</c> so that
/// the shared <c>LoginPageViewModel</c> sees a working <c>IBrowser</c> /// the shared OIDC login path sees a working <c>IBrowser</c> — the
/// — the custom-scheme browser that hands the OIDC callback off to the /// custom-scheme browser that hands the OIDC callback off to the
/// running instance through the named pipe. Desktop builds do NOT use /// running instance through the named pipe. Desktop builds do NOT use
/// a loopback HTTP listener: the <c>postit://</c> scheme is registered /// a loopback HTTP listener: the <c>postit://</c> scheme is registered
/// with the OS at install time and the browser is whatever the user /// with the OS at install time and the browser is whatever the user
@ -24,7 +24,7 @@ internal static class PlatformBootstrap
// Use the custom-scheme redirect on Desktop. Loopback is only // Use the custom-scheme redirect on Desktop. Loopback is only
// a fallback for platforms that cannot register postit:// // a fallback for platforms that cannot register postit://
// (see Settings.DefaultLoopbackRedirectUri for that path). // (see Settings.DefaultLoopbackRedirectUri for that path).
Platform.DefaultRedirectUri = Settings.DefaultDesktopRedirectUri; Platform.DefaultRedirectUri = AuthenticationSettings.DefaultDesktopRedirectUri;
Platform.CustomScheme = "postit"; Platform.CustomScheme = "postit";
} }
} }

View file

@ -19,6 +19,7 @@
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets> <IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets> <PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Material.Avalonia" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\PostIt\PostIt.csproj" /> <ProjectReference Include="..\PostIt\PostIt.csproj" />

View file

@ -5,6 +5,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
@ -60,8 +61,18 @@ public partial class App : Application
// Vues // Vues
services.AddTransient<MainPage>(); services.AddTransient<MainPage>();
services.AddTransient<LoginPage>(); // SettingsPage is a singleton: there must be one and only one
services.AddTransient<SettingsPage>(); // instance of the settings UI for the lifetime of the app.
// This guarantees that (a) the bindings always reflect the
// current in-memory Settings state, (b) the page already has
// its DataContext wired up at composition-root time (see
// below), and (c) the OpenSettingsRequested handler is a
// pure push with a no-op-if-already-on-top guard, never a
// re-resolution from DI. Transient would let the user
// accumulate stale SettingsPage instances on the navigation
// stack, each bound to a fresh SettingsViewModel and missing
// any in-flight edits.
services.AddSingleton<SettingsPage>();
services.AddTransient<HomePage>(); services.AddTransient<HomePage>();
services.AddTransient<SignaturePage>(); services.AddTransient<SignaturePage>();
@ -70,8 +81,6 @@ public partial class App : Application
services.AddSingleton(api); services.AddSingleton(api);
services.AddSingleton(client); services.AddSingleton(client);
services.AddTransient<MainPageViewModel>(); services.AddTransient<MainPageViewModel>();
services.AddTransient<SettingsPageViewModel>();
services.AddTransient<LoginPageViewModel>();
services.AddTransient<HomePageViewModel>(); services.AddTransient<HomePageViewModel>();
services.AddTransient<SignaturePageViewModel>(); services.AddTransient<SignaturePageViewModel>();
@ -86,10 +95,9 @@ public partial class App : Application
// Bind the canonical Settings to the static accessor so any // Bind the canonical Settings to the static accessor so any
// code path that can't easily take a constructor parameter // code path that can't easily take a constructor parameter
// (designer surfaces, Avalonia data templates, the // (designer surfaces, Avalonia data templates) still gets
// LoginPage.axaml.cs fallback) still gets the same instance // the same instance the rest of the app is using. Idempotent:
// the rest of the app is using. Idempotent: re-binding from // re-binding from a second App boot (tests) is a no-op.
// a second App boot (tests) is a no-op.
Settings.BindToServiceProvider(provider); Settings.BindToServiceProvider(provider);
Services = provider; Services = provider;
@ -97,6 +105,33 @@ public partial class App : Application
DataTemplates.Clear(); DataTemplates.Clear();
DataTemplates.Add(new ViewLocator(provider)); DataTemplates.Add(new ViewLocator(provider));
// Wire the Settings singleton onto the SettingsPage singleton
// once, at composition time. The page is registered as a
// singleton (see above) precisely so this binding is stable
// for the lifetime of the app: every push to / pop from the
// navigation stack finds the same ContentPage with the same
// DataContext, and the TwoWay bindings inside the page keep
// mutating the same in-memory Settings instance that the rest
// of the app reads (OidcClientOptions construction, etc.).
provider.GetRequiredService<SettingsPage>().DataContext = settings;
// Settings.DarkMode was previously a dead field: it round-
// tripped through the settings file and the SettingsPage
// CheckBox, but no consumer ever read it. Wire it here to
// Application.RequestedThemeVariant so the toggle takes
// effect immediately, and seed the initial theme from the
// value Load() just populated (so a dark-mode user lands on
// a dark window on first launch, not on a default-light
// window that flips after the user touches the toggle).
ApplyDarkMode(settings);
settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Settings.DarkMode))
{
ApplyDarkMode(settings);
}
};
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
var homePage = provider.GetRequiredService<HomePage>(); var homePage = provider.GetRequiredService<HomePage>();
@ -125,6 +160,42 @@ public partial class App : Application
_ = nav.PopToRootAsync(); _ = 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);
};
// When the user clicks the "Paramètres" button on the
// session banner, push the SettingsPage singleton on top
// of the current navigation stack. The DataContext is
// already wired at composition time (see the
// provider.GetRequiredService<SettingsPage>().DataContext
// assignment above), so this handler is a pure
// navigation concern.
//
// Anti-empilement guard: if the SettingsPage is already
// at the top of the stack, do nothing. NavigationPage's
// PushAsync does not deduplicate; calling it twice with
// the same instance would push it a second time and the
// user would have to tap Back twice to leave. Reference
// comparison is correct here because SettingsPage is a
// singleton — there is exactly one instance to compare
// against.
sessionStatus.OpenSettingsRequested += () =>
{
var w = (MainWindow)((IClassicDesktopStyleApplicationLifetime)ApplicationLifetime!).MainWindow!;
var settingsPage = provider.GetRequiredService<SettingsPage>();
var stack = w.NavRoot.NavigationStack;
if (stack.Count > 0 && ReferenceEquals(stack[stack.Count - 1], settingsPage))
{
return;
}
_ = w.NavRoot.PushAsync(settingsPage);
};
window.Opened += async (_, _) => await BootAsync(provider, api, window); window.Opened += async (_, _) => await BootAsync(provider, api, window);
} }
else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView) else if (ApplicationLifetime is ISingleViewApplicationLifetime singleView)
@ -136,6 +207,12 @@ public partial class App : Application
} }
} }
private static void ApplyDarkMode(Settings settings)
{
Application.Current!.RequestedThemeVariant =
settings.DarkMode ? ThemeVariant.Dark : ThemeVariant.Light;
}
/// <summary> /// <summary>
/// Run once after the main window is shown: try to refresh the /// Run once after the main window is shown: try to refresh the
/// cached OIDC tokens silently; on success, push MainPage on top /// cached OIDC tokens silently; on success, push MainPage on top
@ -154,10 +231,22 @@ public partial class App : Application
sessionStatus.Refresh(); sessionStatus.Refresh();
if (!refreshed) return; if (!refreshed) return;
await PushMainPageAsync(provider, window).ConfigureAwait(true);
}
/// <summary>
/// Resolve a fresh <c>MainPage</c> + VM from DI and push it on top
/// of the current navigation stack. Used both by <see cref="BootAsync"/>
/// (silent refresh at boot) and by <c>SessionStatusViewModel.LoginSucceeded</c>
/// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart.
/// </summary>
private static async Task PushMainPageAsync(IServiceProvider provider, MainWindow window)
{
var mainVm = provider.GetRequiredService<MainPageViewModel>(); var mainVm = provider.GetRequiredService<MainPageViewModel>();
var mainPage = provider.GetRequiredService<MainPage>(); var mainPage = provider.GetRequiredService<MainPage>();
mainPage.DataContext = mainVm; mainPage.DataContext = mainVm;
await window.NavRoot.PushAsync(mainPage); await window.NavRoot.PushAsync(mainPage).ConfigureAwait(true);
} }
private bool TryHandOffCustomSchemeUrl() private bool TryHandOffCustomSchemeUrl()

View file

@ -40,6 +40,11 @@ public sealed class BlogApiClient
public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix) public BlogApiClient(YavscApiClient api, string pathPrefix = DefaultPathPrefix)
{ {
_api = api ?? throw new ArgumentNullException(nameof(api)); _api = api ?? throw new ArgumentNullException(nameof(api));
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
api.Http.BaseAddress = new Uri(api.Settings.BlogsApiUrl);
_pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix; _pathPrefix = pathPrefix?.TrimStart('/') ?? DefaultPathPrefix;
} }

View file

@ -7,8 +7,8 @@ namespace PostIt.Services;
/// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does /// Authorization Code + PKCE flow. The shared <c>PostIt</c> library does
/// not reference any UI framework; platform projects (PostIt.Android, /// not reference any UI framework; platform projects (PostIt.Android,
/// PostIt.Desktop, PostIt.Browser) populate this class once at startup so /// PostIt.Desktop, PostIt.Browser) populate this class once at startup so
/// the shared <c>LoginPageViewModel</c> can drive a native browser without /// the shared OIDC login path can drive a native browser without taking
/// taking a hard dependency on any specific UI toolkit. /// a hard dependency on any specific UI toolkit.
/// </summary> /// </summary>
public static class Platform public static class Platform
{ {

View file

@ -8,6 +8,7 @@ using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using PostIt.ViewModels;
namespace PostIt.Services; namespace PostIt.Services;
@ -29,10 +30,10 @@ public class YavscApiClient : IAsyncDisposable
// network latency + JWT validation on the server side. // network latency + JWT validation on the server side.
private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60); private static readonly TimeSpan RefreshSkew = TimeSpan.FromSeconds(60);
private readonly Settings _settings; public Settings Settings {  get; }
private readonly OidcClient _oidc; private readonly OidcClient _oidc;
private readonly TokenStore _store; private readonly TokenStore _store;
private readonly HttpClient _http; public HttpClient Http { get; }
private readonly BearerTokenHandler _bearer; private readonly BearerTokenHandler _bearer;
private readonly SemaphoreSlim _refreshGate = new(1, 1); private readonly SemaphoreSlim _refreshGate = new(1, 1);
@ -40,24 +41,19 @@ public class YavscApiClient : IAsyncDisposable
public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null) public YavscApiClient(Settings settings, TokenStore store, OidcClient? oidc = null)
{ {
_settings = settings; Settings = settings;
_store = store; _store = store;
_oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions()); _oidc = oidc ?? new OidcClient(settings.GetOidcClientOptions());
_bearer = new BearerTokenHandler(this); _bearer = new BearerTokenHandler(this);
_http = new HttpClient(_bearer, disposeHandler: true) Http = new HttpClient(_bearer, disposeHandler: true);
{
// ApiUrl is e.g. "https://blogs.pschneider.fr/api/v1/" — keep the
// trailing slash so relative paths ("posts") resolve correctly.
BaseAddress = new Uri(settings.ApiUrl)
};
_tokens = store.Load(); _tokens = store.Load();
} }
/// <summary> /// <summary>
/// True if a non-expired access token (or a refreshable bundle) is /// 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. /// starts.
/// </summary> /// </summary>
public bool HasValidSession public bool HasValidSession
@ -74,9 +70,9 @@ public class YavscApiClient : IAsyncDisposable
/// <summary> /// <summary>
/// The current access token, or null if no session is active. /// The current access token, or null if no session is active.
/// Surfaced so the LoginPageViewModel can mirror it onto its own /// Surfaced so consumers (e.g. <c>HomePage</c>) can mirror it onto
/// observable property (and so the OIDC id_token / claims can be /// their own observable properties and so the OIDC id_token / claims
/// shown in the UI). /// can be shown in the UI.
/// </summary> /// </summary>
public string? CurrentAccessToken => _tokens?.AccessToken; public string? CurrentAccessToken => _tokens?.AccessToken;
@ -87,9 +83,7 @@ public class YavscApiClient : IAsyncDisposable
/// <param name="progress">Optional sink for the discrete phases of /// <param name="progress">Optional sink for the discrete phases of
/// the flow; the UI uses this to render a debug-friendly status /// the flow; the UI uses this to render a debug-friendly status
/// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode /// (Discovering → OpeningBrowser → AwaitingCallback → ExchangingCode
/// → Success / Error). The same caller can also rely on /// → Success / Error).</param>
/// <see cref="LoginPageViewModel.StatusMessage"/> for the human
/// text (URLs, error detail).</param>
public async Task LoginInteractiveAsync( public async Task LoginInteractiveAsync(
IProgress<OIDCLoginPhase>? progress = null, IProgress<OIDCLoginPhase>? progress = null,
CancellationToken ct = default) CancellationToken ct = default)
@ -103,7 +97,7 @@ public class YavscApiClient : IAsyncDisposable
throw new InvalidOperationException("No browser is available on this platform."); throw new InvalidOperationException("No browser is available on this platform.");
} }
var client = new OidcClient(_settings.GetOidcClientOptions(browser)); var client = new OidcClient(Settings.GetOidcClientOptions(browser));
// OidcClient.LoginAsync builds the authorize URL, calls // OidcClient.LoginAsync builds the authorize URL, calls
// IBrowser.InvokeAsync (which on desktop hands the user off // IBrowser.InvokeAsync (which on desktop hands the user off
@ -197,7 +191,7 @@ public class YavscApiClient : IAsyncDisposable
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false); var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var dto = await JsonSerializer.DeserializeAsync<T>(stream, var dto = await JsonSerializer.DeserializeAsync<T>(stream,
@ -223,7 +217,7 @@ public class YavscApiClient : IAsyncDisposable
CancellationToken ct = default) CancellationToken ct = default)
{ {
using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false); using var response = await SendAsync(method, path, body, ct).ConfigureAwait(false);
response.EnsureSuccessStatusCode(); await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@ -247,7 +241,7 @@ public class YavscApiClient : IAsyncDisposable
using var req = new HttpRequestMessage(method, path); using var req = new HttpRequestMessage(method, path);
if (body is not null) if (body is not null)
req.Content = JsonContent.Create(body); req.Content = JsonContent.Create(body);
var response = await _http.SendAsync(req, ct).ConfigureAwait(false); var response = await Http.SendAsync(req, ct).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized) if (response.StatusCode == HttpStatusCode.Unauthorized)
{ {
@ -259,12 +253,54 @@ public class YavscApiClient : IAsyncDisposable
using var retry = new HttpRequestMessage(method, path); using var retry = new HttpRequestMessage(method, path);
if (body is not null) if (body is not null)
retry.Content = JsonContent.Create(body); retry.Content = JsonContent.Create(body);
response = await _http.SendAsync(retry, ct).ConfigureAwait(false); response = await Http.SendAsync(retry, ct).ConfigureAwait(false);
} }
return response; return response;
} }
/// <summary>
/// Replaces the bare <c>response.EnsureSuccessStatusCode()</c>
/// call site with one that surfaces the response body in the
/// thrown exception. The default behaviour truncates the
/// diagnostic to "Response status code does not indicate
/// success: 400 (Bad Request)." — useless when the server is
/// an ASP.NET Core action returning a <c>ProblemDetails</c>
/// that names the field that failed ModelState validation.
/// The VM's <c>catch (Exception ex)</c> in
/// <c>MainPageViewModel.ExecuteAsync</c> shows
/// <c>ex.Message</c> on the status bar, so embedding the body
/// here is enough to make the next "click Save" self-explanatory
/// (e.g. <i>"Error: 400 — The Title field is required."</i>).
/// </summary>
private static async Task EnsureSuccessOrThrowAsync(HttpResponseMessage response, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
// Read the body before throwing; once the response is
// disposed, the stream is gone. We bound the read to a few
// KB so a hostile server can't make us buffer megabytes
// just to format an error message.
string body = string.Empty;
try
{
var raw = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(raw))
{
body = raw.Length > 1024 ? raw[..1024] + "…" : raw;
}
}
catch
{
// Body unreadable: fall back to the default message.
}
var msg = body.Length > 0
? $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}"
: $"{(int)response.StatusCode} {response.ReasonPhrase}";
throw new HttpRequestException(msg, inner: null, statusCode: response.StatusCode);
}
/// <summary> /// <summary>
/// Lock the refresh path so concurrent callers don't each rotate /// Lock the refresh path so concurrent callers don't each rotate
/// the refresh token (which Auth0 invalidates on first use). /// the refresh token (which Auth0 invalidates on first use).
@ -326,7 +362,7 @@ public class YavscApiClient : IAsyncDisposable
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
_http.Dispose(); Http.Dispose();
_refreshGate.Dispose(); _refreshGate.Dispose();
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
} }

View file

@ -1,13 +1,122 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using System; using System;
using System.Text.Json.Serialization;
public partial class AuthenticationSettings : ObservableObject public partial class AuthenticationSettings : ObservableObject
{ {
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter
/// in <c>PostIt.Android/Properties/AndroidManifest.xml</c> must match.
/// </summary>
public const string AndroidRedirectUri = "android://postit-signin";
public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public static string DefaultClientId { get; internal set; } = "postit";
[ObservableProperty] [ObservableProperty]
public partial string Authority { get; set; } public partial string Authority { get; set; }
[ObservableProperty] [ObservableProperty]
public partial string ClientId { get; set; } public partial string ClientId { get; set; }
}
[ObservableProperty]
public partial string[] Scopes { get; set; }
/// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/>
/// (custom URI scheme) which is the right answer for desktop
/// production builds. Mobile platforms must set this to
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>.
/// </summary>
[ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri;
/// <summary>
/// Space-separated view of <see cref="Scopes"/>. Exists for the
/// <c>SettingsPage</c> TextBox binding — a <c>string[]</c> does not
/// round-trip through XAML binding to <c>TextBox.Text</c>, so we
/// expose the array as a string here and re-parse on assignment.
/// <para>
/// <c>[JsonIgnore]</c> on purpose: <see cref="Scopes"/> is the
/// persisted shape (matches the on-disk format in
/// <c>postit-settings.json</c> and the runtime contract in
/// <see cref="PostIt.ViewModels.Settings.GetOidcClientOptions"/>).
/// Writing this property back to disk would duplicate the
/// information and confuse the deserializer.
/// </para>
/// </summary>
[JsonIgnore]
[ObservableProperty]
public partial string ScopeListText { get; set; } = string.Empty;
/// <summary>
/// Refresh <see cref="ScopeListText"/> from <see cref="Scopes"/> so
/// the TextBox shows the current persisted state after a Load().
/// Called from <c>Settings.ApplyJson</c> on each disk / embedded
/// hydration; the source generator's <c>OnScopesChanged</c> partial
/// below keeps the two in sync in the other direction (edits made
/// in the TextBox).
/// </summary>
public void RefreshScopeListText()
{
ScopeListText = Scopes is null ? string.Empty : string.Join(' ', Scopes);
}
partial void OnScopeListTextChanged(string value)
{
if (Scopes is null)
{
Scopes = Array.Empty<string>();
}
// Split on any whitespace, drop empties. Matches what
// string.Join(' ', Scopes) produces when Scopes is null-free,
// so a round-trip (Display → Edit → Display) is lossless
// for sane inputs.
var parts = value?.Split(
new[] { ' ', '\t', '\n', '\r' },
StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();
// Skip the write if the parsed array is equal to the current
// one — avoids a PropertyChanged loop between OnScopesChanged
// and OnScopeListTextChanged when RefreshScopeListText runs.
if (Scopes is not null && Scopes.Length == parts.Length)
{
var same = true;
for (var i = 0; i < parts.Length; i++)
{
if (!string.Equals(Scopes[i], parts[i], StringComparison.Ordinal))
{
same = false;
break;
}
}
if (same) return;
}
Scopes = parts;
}
partial void OnScopesChanged(string[] value)
{
// Keep ScopeListText in sync when Scopes is reassigned from
// outside (JSON hydration, MergeScopes, programmatic
// updates). Compute the new value and only fire if it
// differs from what's already shown, otherwise the TextBox
// would briefly flicker / re-set the caret on every load.
var newText = value is null ? string.Empty : string.Join(' ', value);
if (!string.Equals(ScopeListText, newText, StringComparison.Ordinal))
{
ScopeListText = newText;
}
}
}

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -21,18 +20,18 @@ public class ViewLocator : IDataTemplate
_services = services; _services = services;
} }
public Control Build(object data) public Control Build(object? data)
{ {
return data switch return data switch
{ {
MainPageViewModel => _services.GetRequiredService<MainPage>(), MainPageViewModel => _services.GetRequiredService<MainPage>(),
SettingsPageViewModel => _services.GetRequiredService<SettingsPage>(), Settings => _services.GetRequiredService<SettingsPage>(),
LoginPageViewModel => _services.GetRequiredService<LoginPage>(),
HomePageViewModel => _services.GetRequiredService<HomePage>(), HomePageViewModel => _services.GetRequiredService<HomePage>(),
SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => _services.GetRequiredService<SignaturePage>(),
null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };
} }
public bool Match(object data) => data is ViewModelBase; public bool Match(object? data) => data is ViewModelBase;
} }

View file

@ -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; }
/// <summary>
/// URL of the Yavsc.Org account-registration page.
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
/// Empty when the authority is not configured.
/// </summary>
public string RegisterUrl =>
BuildExternalUrl("/Account/Register");
/// <summary>
/// URL of the Yavsc.Org password-reset page (open to anonymous users).
/// Derived from <see cref="Settings.Authentication"/>'s Authority.
/// Empty when the authority is not configured.
/// </summary>
public string ForgotPasswordUrl =>
BuildExternalUrl("/Account/ForgotPassword");
public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl);
public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl);
/// <summary>
/// Canonical <see cref="Settings.Authentication"/> 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.
/// </summary>
public string ExternalUrl => BuildExternalUrl(string.Empty);
/// <summary>
/// OIDC discovery URL the client actually calls during login:
/// <c>ExternalUrl + "/.well-known/openid-configuration"</c>. Surfaced in
/// <see cref="StatusMessage"/> on failure so the operator can copy it
/// verbatim and verify reachability from a browser.
/// </summary>
public string DiscoveryUrl =>
string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration";
/// <summary>
/// True when the settings file is missing or <c>Authentication.Authority</c>
/// is empty. The LoginPage surfaces a banner in that case and disables
/// the Register / Forgot password buttons.
/// </summary>
public bool ConfigMissing =>
string.IsNullOrWhiteSpace(Settings.Authentication?.Authority);
/// <summary>
/// Localised banner shown when <see cref="ConfigMissing"/> is true.
/// The path follows the XDG spec on Linux (where PostIt.Desktop runs):
/// the file is expected at <c>~/.config/PostIt/postit-settings.json</c>.
/// </summary>
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;
}
/// <summary>
/// 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 <see cref="TokenStore"/>.
/// </summary>
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; }
/// <summary>
/// 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.
/// </summary>
private OIDCLoginPhase _phase = OIDCLoginPhase.Idle;
public OIDCLoginPhase Phase
{
get => _phase;
private set
{
if (this.SetProperty(ref _phase, value))
OnPropertyChanged(nameof(PhaseLabel));
}
}
/// <summary>
/// Human-readable label for <see cref="Phase"/>. French to match
/// the rest of the UI. Computed once per phase change.
/// </summary>
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); }
/// <summary>
/// Optional override used by tests. When set, this factory is called
/// instead of <see cref="Platform.CreateBrowser"/> to obtain the
/// <see cref="IBrowser"/> instance.
/// </summary>
public Func<IBrowser?>? BrowserFactoryOverride { get; set; }
/// <summary>
/// Optional override used by tests. When set, this delegate replaces
/// the call to <see cref="Settings.Load"/> at the start of
/// <see cref="LoginAsync"/>, so tests can inject a Settings object
/// without it being overwritten by the user/embedded default.
/// </summary>
public Func<Task>? SettingsLoadOverride { get; set; }
/// <summary>
/// Optional override used by tests. When set, the VM hands this
/// pre-built <see cref="YavscApiClient"/> to itself instead of
/// constructing a fresh one.
/// </summary>
public YavscApiClient? ApiClientOverride { get; set; }
public Action LoginSucceeded { get; internal set; }
private YavscApiClient? _api;
/// <summary>
/// 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 <c>postit://callback</c> re-launches. Tests that
/// don't want the DI bind pass an explicit <c>Settings</c> 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.
/// </summary>
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 */ }
}
/// <summary>
/// Test-friendly constructor: caller supplies pre-loaded
/// <paramref name="settings"/>, an optional
/// <paramref name="browserFactoryOverride"/> that bypasses the
/// static <see cref="Platform"/> indirection, and an optional
/// pre-built <paramref name="apiClient"/> for end-to-end
/// scenarios where the test owns the wiring.
/// </summary>
public LoginPageViewModel(
Settings settings,
Func<IBrowser?>? 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<OIDCLoginPhase>(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}";
}
}
/// <summary>
/// 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.
/// </summary>
private async Task LoginInteractiveCoreAsync(
YavscApiClient api,
IProgress<OIDCLoginPhase>? 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;
}
}
/// <summary>
/// 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.
/// </summary>
private static string SettingsFileHint()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appData, "PostIt", "postit-settings.json");
}
/// <summary>
/// Build the on-disk <see cref="TokenStore"/> used by
/// <see cref="YavscApiClient"/>. The token bundle lives in
/// <c>~/.config/PostIt/tokens.json</c> on Linux; the same path
/// layout is used on every platform for predictability.
/// </summary>
private static TokenStore BuildTokenStore()
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var path = Path.Combine(appData, "PostIt", "tokens.json");
return new TokenStore(path);
}
}

View file

@ -1,9 +1,7 @@
using System; using System;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Models; using PostIt.Models;
@ -13,13 +11,33 @@ namespace PostIt.ViewModels;
public partial class MainPageViewModel : ViewModelBase public partial class MainPageViewModel : ViewModelBase
{ {
/// <summary>Window/tab title. Cosmetic — bound by
/// <c>MainPage.axaml</c> if at all. Not the post title.</summary>
[ObservableProperty] [ObservableProperty]
public partial string Title { get; set; } public partial string WindowTitle { get; set; }
/// <summary>Editor buffer for the post title. Bound TwoWay to
/// the title <c>TextBox</c> in <c>MainPage.axaml</c>. The Save
/// command reads from this buffer (not from
/// <see cref="SelectedPost"/>) so that typing into a freshly
/// mounted editor (no post selected yet) is captured. With the
/// previous "{Binding SelectedPost.Title}" binding, the user's
/// keystrokes were silently dropped whenever
/// <c>SelectedPost was null</c>, which made the editor a trap
/// and caused Save to POST a <c>BlogPost</c> with an empty
/// title — hence the 400 "The Title field is required".</summary>
[ObservableProperty]
public partial string DraftTitle { get; set; }
/// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary>
[ObservableProperty]
public partial string DraftArticle { get; set; }
[ObservableProperty] [ObservableProperty]
public partial ViewModelBase? CurrentViewModel { get; set; } public partial ViewModelBase? CurrentViewModel { get; set; }
public SettingsPageViewModel SettingsModel { get; } public Settings SettingsModel { get; }
[ObservableProperty] [ObservableProperty]
public partial string StatusMessage { get; set; } public partial string StatusMessage { get; set; }
@ -39,9 +57,6 @@ public partial class MainPageViewModel : ViewModelBase
[ObservableProperty] [ObservableProperty]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
[ObservableProperty]
ThemeVariant themeVariant = ThemeVariant.Default;
[ObservableProperty] [ObservableProperty]
public partial Settings Settings { get; private set; } public partial Settings Settings { get; private set; }
@ -60,7 +75,7 @@ public partial class MainPageViewModel : ViewModelBase
public MainPageViewModel() public MainPageViewModel()
{ {
Init(null); Init(null);
SettingsModel = new SettingsPageViewModel(); SettingsModel = new Settings();
BlogClient = null; BlogClient = null;
} }
@ -83,7 +98,9 @@ public partial class MainPageViewModel : ViewModelBase
// (thread-safe dispatcher marshalling) so the duplicate // (thread-safe dispatcher marshalling) so the duplicate
// instance is now merely wasteful, not dangerous. // instance is now merely wasteful, not dangerous.
Settings = settings ?? new Settings(); Settings = settings ?? new Settings();
Title = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty;
DraftArticle = string.Empty;
CurrentViewModel = this; CurrentViewModel = this;
} }
@ -94,7 +111,7 @@ public partial class MainPageViewModel : ViewModelBase
/// </summary> /// </summary>
public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null) public MainPageViewModel(BlogApiClient blogClient, Settings? settings = null)
{ {
SettingsModel = new SettingsPageViewModel(); SettingsModel = new Settings();
BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));; BlogClient = blogClient ?? throw new ArgumentNullException(nameof(blogClient));;
Init(settings); Init(settings);
@ -102,10 +119,28 @@ public partial class MainPageViewModel : ViewModelBase
partial void OnSearchTextChanged(string value) => ApplyFilter(); partial void OnSearchTextChanged(string value) => ApplyFilter();
partial void OnSelectedPostChanged(BlogPost? value) => UpdateCommandStates(); partial void OnSelectedPostChanged(BlogPost? value)
{
// Mirror the selection into the editor buffer so the
// XAML-bound TextBox/TextEditor show the right content
// when the user clicks a post in the list. When the
// selection is cleared (e.g. after a successful create
// rebinds to the server-issued record, or Delete
// nulls it out), the buffer is reset so the editor
// doesn't show stale content.
DraftTitle = value?.Title ?? string.Empty;
DraftArticle = value?.Article ?? string.Empty;
UpdateCommandStates();
}
partial void OnIsBusyChanged(bool value) => UpdateCommandStates(); partial void OnIsBusyChanged(bool value) => UpdateCommandStates();
// Save's CanExecute depends on the buffer: the button must
// enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
[RelayCommand] [RelayCommand]
internal async Task LoadPosts() internal async Task LoadPosts()
{ {
@ -128,38 +163,39 @@ public partial class MainPageViewModel : ViewModelBase
[RelayCommand] [RelayCommand]
internal async Task Save() internal async Task Save()
{ {
// No selection means "create a new post from the editor". // The button is already disabled when the title is empty
// The server is the source of truth, so we POST without an id // (see CanSave), but the test path (and any programmatic
// and let BlogApiController assign one. The local view-model // ICommand.Execute) bypasses CanExecute, so we still
// is then rebound to the server-issued record. // guard here. Better to no-op with a status message
if (SelectedPost is null) // than to send a request the server will reject.
if (string.IsNullOrWhiteSpace(DraftTitle))
{ {
var draft = new BlogPost StatusMessage = "Title is required.";
{
Title = string.Empty,
Article = string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
await ExecuteAsync(async () =>
{
var created = await BlogClient.CreatePostAsync(draft);
if (created is not null)
{
SelectedPost = created;
StatusMessage = $"Created post {created.Id}.";
}
});
return; return;
} }
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
if (SelectedPost.Id == 0) // Build a fresh BlogPost from the editor buffer on
// every Save — we no longer mutate SelectedPost in
// place. The previous behaviour copied the buffer
// (which was a no-op when SelectedPost was null)
// back onto the model and relied on a
// [Required] violation to surface the missing
// input; the new shape keeps the editor buffer as
// the single source of truth for outgoing payloads
// and the selected post as a read-only hint for
// the update path.
if (SelectedPost is null || SelectedPost.Id == 0)
{ {
SelectedPost.DateCreated = DateTime.UtcNow; var draft = new BlogPost
SelectedPost.DateModified = DateTime.UtcNow; {
var created = await BlogClient.CreatePostAsync(SelectedPost); Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
var created = await BlogClient.CreatePostAsync(draft);
if (created is not null) if (created is not null)
{ {
SelectedPost = created; SelectedPost = created;
@ -168,8 +204,17 @@ public partial class MainPageViewModel : ViewModelBase
} }
else else
{ {
SelectedPost.DateModified = DateTime.UtcNow; var update = new BlogPost
await BlogClient.UpdatePostAsync(SelectedPost.Id, SelectedPost); {
Id = SelectedPost.Id,
AuthorId = SelectedPost.AuthorId,
Photo = SelectedPost.Photo,
Title = DraftTitle,
Article = DraftArticle ?? string.Empty,
DateCreated = SelectedPost.DateCreated,
DateModified = DateTime.UtcNow,
};
await BlogClient.UpdatePostAsync(SelectedPost.Id, update);
StatusMessage = $"Saved post {SelectedPost.Id}."; StatusMessage = $"Saved post {SelectedPost.Id}.";
} }
@ -261,6 +306,14 @@ public partial class MainPageViewModel : ViewModelBase
DeleteCommand.NotifyCanExecuteChanged(); DeleteCommand.NotifyCanExecuteChanged();
} }
private bool CanSave() => SelectedPost is not null && !IsBusy; /// <summary>Save is enabled as soon as the user has typed
/// a non-whitespace title in the editor, regardless of
/// whether a post is selected. The "no selection" case is
/// the create-new-post path; the "with selection" case is
/// the update path. Both read from the editor buffer.
/// Previously this also required <c>SelectedPost is not null</c>
/// — which contradicted the create-new-post intent and
/// forced the buggy "draft with empty title" branch.</summary>
private bool CanSave() => !IsBusy && !string.IsNullOrWhiteSpace(DraftTitle);
private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy; private bool CanDelete() => SelectedPost is not null && SelectedPost.Id != 0 && !IsBusy;
} }

View file

@ -1,3 +1,5 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Services; using PostIt.Services;
@ -9,7 +11,11 @@ namespace PostIt.ViewModels;
/// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s /// <c>MainWindow.axaml</c>. Mirrors <see cref="YavscApiClient"/>'s
/// session state ("Connecté" / "Déconnecté") and exposes a /// session state ("Connecté" / "Déconnecté") and exposes a
/// <c>Logout</c> command that purges the token store and asks the /// <c>Logout</c> command that purges the token store and asks the
/// navigation owner to route the user back to <c>HomePage</c>. /// navigation owner to route the user back to <c>HomePage</c>, plus
/// a <c>Login</c> command that drives the OIDC interactive flow
/// and raises a <see cref="LoginSucceeded"/> event on success so
/// <c>MainWindow</c> can push <c>MainPage</c> on top of
/// <c>HomePage</c>.
/// ///
/// Construction is deferred until the API client exists; the /// Construction is deferred until the API client exists; the
/// App.axaml.cs wiring sets <see cref="Api"/> after building both, /// App.axaml.cs wiring sets <see cref="Api"/> after building both,
@ -21,12 +27,38 @@ public partial class SessionStatusViewModel : ViewModelBase
/// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary> /// <c>App.axaml.cs</c> listens and swaps the navigation root.</summary>
public event System.Action? LogoutCompleted; public event System.Action? LogoutCompleted;
/// <summary>Raised after <see cref="LoginAsync"/> acquired a valid session;
/// <c>App.axaml.cs</c> listens and pushes <c>MainPage</c> on top of
/// <c>HomePage</c> so the user lands on the blog editor.</summary>
public event System.Action? LoginSucceeded;
/// <summary>Raised when the user clicks the "Paramètres" button on
/// the session banner. <c>App.axaml.cs</c> listens and pushes
/// <c>SettingsPage</c> (resolved from DI, bound to the canonical
/// <c>Settings</c> singleton) on top of the current navigation
/// stack. Same event pattern as <see cref="LogoutCompleted"/> and
/// <see cref="LoginSucceeded"/> so the VM stays decoupled from
/// <c>NavigationPage</c> / window lifetime.</summary>
public event System.Action? OpenSettingsRequested;
[ObservableProperty] [ObservableProperty]
public partial bool IsLoggedIn { get; private set; } public partial bool IsLoggedIn { get; private set; }
/// <summary>Inverse of <see cref="IsLoggedIn"/>, for XAML bindings
/// (the banner shows the Login button when the user is logged out).
/// Updated from <see cref="Refresh"/>.</summary>
[ObservableProperty]
public partial bool IsLoggedOut { get; private set; } = true;
[ObservableProperty] [ObservableProperty]
public partial string SessionLabel { get; private set; } = "Déconnecté"; public partial string SessionLabel { get; private set; } = "Déconnecté";
/// <summary>True while a Login flow is in flight; the Login button
/// binds <c>IsEnabled</c> to <c>!IsBusy</c> via
/// <see cref="LoginCommand"/>'s <c>CanExecute</c>.</summary>
[ObservableProperty]
public partial bool IsBusy { get; private set; }
/// <summary>The API client backing the banner. Set once at startup; /// <summary>The API client backing the banner. Set once at startup;
/// the banner polls <c>HasValidSession</c> on demand rather than /// the banner polls <c>HasValidSession</c> on demand rather than
/// subscribing to a stream — the session state only changes at /// subscribing to a stream — the session state only changes at
@ -50,9 +82,58 @@ public partial class SessionStatusViewModel : ViewModelBase
{ {
var has = Api?.HasValidSession ?? false; var has = Api?.HasValidSession ?? false;
IsLoggedIn = has; IsLoggedIn = has;
IsLoggedOut = !has;
SessionLabel = has ? "Connecté" : "Déconnecté"; SessionLabel = has ? "Connecté" : "Déconnecté";
} }
/// <summary>
/// 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 <see cref="Refresh"/> call
/// reverts to "Connecté" / "Déconnecté".
/// </summary>
public void SetError(string message)
{
IsLoggedIn = false;
IsLoggedOut = true;
SessionLabel = message;
}
/// <summary>
/// Drive the OIDC interactive login. On success, refreshes
/// the banner state and raises <see cref="LoginSucceeded"/> so
/// the navigation owner can push <c>MainPage</c>. On failure,
/// surfaces the error in the banner via <see cref="SetError"/>.
/// </summary>
[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] [RelayCommand]
public async System.Threading.Tasks.Task LogoutAsync() public async System.Threading.Tasks.Task LogoutAsync()
{ {
@ -61,4 +142,11 @@ public partial class SessionStatusViewModel : ViewModelBase
Refresh(); Refresh();
LogoutCompleted?.Invoke(); LogoutCompleted?.Invoke();
} }
[RelayCommand]
public async System.Threading.Tasks.Task OpenSettingsCommand()
{
OpenSettingsRequested?.Invoke();
await System.Threading.Tasks.Task.CompletedTask;
}
} }

View file

@ -1,38 +1,21 @@
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using IdentityModel.OidcClient; using IdentityModel.OidcClient;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
[assembly: InternalsVisibleTo("PostIt.Tests")] [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"; const string SettingsFileName = "postit-settings.json";
IStorageFolder? folder = null;
/// <summary>
/// Legacy loopback redirect URI. The post-2026.6 production flow
/// uses the custom URI scheme (<see cref="DefaultDesktopRedirectUri"/>
/// on desktop, <see cref="AndroidRedirectUri"/> on Android) so the
/// OS hands the callback to the running instance without a TCP
/// listener. The loopback constant stays here so test fixtures
/// (which spin up an in-process OidcStubAuthority) keep working,
/// but it is no longer used as a default anywhere in production.
/// If you are still pointing your production <c>postit-settings.json</c>
/// at this URI, switch to <c>postit://callback</c> and remove the
/// matching entry from the Yavsc.Org server's allowed redirect URIs.
/// </summary>
public const string DefaultLoopbackRedirectUri = "http://127.0.0.1:7890/";
/// <summary> /// <summary>
/// Redirect URI used by the Android app. The corresponding IntentFilter /// Redirect URI used by the Android app. The corresponding IntentFilter
@ -40,13 +23,7 @@ public partial class Settings : ObservableObject
/// </summary> /// </summary>
public const string AndroidRedirectUri = "android://postit-signin"; public const string AndroidRedirectUri = "android://postit-signin";
/// <summary>
/// Default custom-scheme redirect URI on Desktop. The OS routes the
/// callback to the running PostIt instance via the named-pipe
/// hand-off in <see cref="PostIt.Services.SingleInstance"/>
/// (RFC 8252 §7.1). Production Desktop builds use this.
/// </summary>
public const string DefaultDesktopRedirectUri = "postit://callback";
/// <summary> /// <summary>
/// Process-wide canonical <see cref="Settings"/> instance, wired up /// Process-wide canonical <see cref="Settings"/> instance, wired up
@ -111,21 +88,61 @@ public partial class Settings : ObservableObject
public partial bool DarkMode { get; set; } = false; public partial bool DarkMode { get; set; } = false;
[ObservableProperty] [ObservableProperty]
public partial string ApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty]
public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
/// <summary> /// <summary>
/// OAuth redirect URI. Defaults to <see cref="DefaultDesktopRedirectUri"/> /// Catch top-level mutations: the four ObservableProperty
/// (custom URI scheme) which is the right answer for desktop /// setters above all funnel through here, and we flip
/// production builds. Mobile platforms must set this to /// <see cref="IsDirty"/> in lock-step. Sub-property mutations
/// <see cref="AndroidRedirectUri"/> before calling <c>LoginAsync</c>. /// (e.g. <c>Authentication.Authority</c>) are caught by the
/// subscription wired up in <see cref="OnAuthenticationChanged"/>
/// below. <see cref="ApplyJson"/> disables the flag during bulk
/// hydration so the disk load itself does not count as a user
/// edit.
/// </summary>
private void MarkDirty() => IsDirty = true;
partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
/// <summary>
/// Authentication can be reassigned wholesale by
/// <see cref="ApplyJson"/>; on each reassignment we (re)wire a
/// <c>PropertyChanged</c> listener so sub-property edits
/// (Authority, ClientId, RedirectUri, Scopes) are picked up
/// by the dirty tracker. We don't filter on PropertyName: any
/// nested setter is treated as a user edit, which matches the
/// user's mental model ("I typed in a field, the page is now
/// dirty").
/// </summary>
partial void OnAuthenticationChanged(AuthenticationSettings value)
{
if (value is not null)
{
value.PropertyChanged += (_, _) => MarkDirty();
}
MarkDirty();
}
public bool Loaded { get; private set; } = false;
/// <summary>
/// True when the in-memory state has drifted from the last
/// <see cref="Load"/> or <see cref="Save"/> snapshot. The
/// Settings page binds the Sauver button's <c>IsEnabled</c> to
/// this flag, so it only enables when the user has actually
/// touched something since the last load / save. Cleared by
/// <see cref="Load"/> (and by <see cref="ApplyJson"/>), set by
/// every successful setter on the four top-level mutable
/// properties and on the sub-properties of
/// <see cref="Authentication"/>.
/// </summary> /// </summary>
[ObservableProperty] [ObservableProperty]
public partial string RedirectUri { get; set; } = DefaultDesktopRedirectUri; public partial bool IsDirty { get; private set; } = false;
[ObservableProperty]
public partial string[] Scopes { get; set; }
public bool Loaded { get; private set; } = false;
/// <summary> /// <summary>
/// Guards every mutation of the observable state. <c>[ObservableProperty]</c> /// Guards every mutation of the observable state. <c>[ObservableProperty]</c>
@ -158,8 +175,8 @@ public partial class Settings : ObservableObject
{ {
Authority = Authentication.Authority, Authority = Authentication.Authority,
ClientId = Authentication.ClientId, ClientId = Authentication.ClientId,
RedirectUri = RedirectUri, RedirectUri = Authentication.RedirectUri,
Scope = string.Join(' ', this.Scopes), Scope = string.Join(' ', MergeScopes(this.Authentication.Scopes)),
TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody, TokenClientCredentialStyle = IdentityModel.Client.ClientCredentialStyle.PostBody,
PostLogoutRedirectUri = "https//yavsc.pschneider.fr", PostLogoutRedirectUri = "https//yavsc.pschneider.fr",
// PKCE is enabled by default when no client_secret is provided. // PKCE is enabled by default when no client_secret is provided.
@ -172,6 +189,48 @@ public partial class Settings : ObservableObject
} }
} }
/// <summary>
/// Scopes the PostIt client always requires from the OIDC provider,
/// regardless of what the user has in their settings file.
///
/// <para>PostIt calls into the Blog API (and any other Yavsc API
/// gated by an <c>[Authorize("…Scope")]</c> 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. <c>blogs</c>) are still their choice — we only force the
/// structural ones that OIDC itself needs.</para>
/// </summary>
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
};
/// <summary>
/// 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. <c>null</c> or empty input
/// is fine — we still emit the built-ins.
/// </summary>
internal static IEnumerable<string> MergeScopes(string[]? userScopes)
{
var seen = new HashSet<string>(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() internal void Load()
{ {
if (Loaded) return; if (Loaded) return;
@ -284,10 +343,37 @@ public partial class Settings : ObservableObject
{ {
this.Authentication = settings.Authentication; this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode; this.DarkMode = settings.DarkMode;
this.ApiUrl = settings.ApiUrl; if (!(settings.Authentication is null))
this.RedirectUri = string.IsNullOrWhiteSpace(settings.RedirectUri) ? DefaultDesktopRedirectUri : settings.RedirectUri; {
this.Scopes = settings.Scopes; 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;
}
} }
// A disk load (or an embedded-resource fallback) is the
// baseline, not a user edit. Clear the dirty flag last
// so the OnAuthenticationChanged / sub-property fan-out
// triggered by the assignments above doesn't leave it
// stuck at true.
IsDirty = false;
// Refresh the space-separated ScopeListText view after
// hydration so the SettingsPage TextBox reflects the
// loaded scopes (and not the default empty string the
// ObservableProperty was constructed with). OnScopesChanged
// already tries to do this, but it skips when the new
// array parses to the same text — calling explicitly
// forces a re-sync and normalises any whitespace the
// JSON might have introduced.
this.Authentication?.RefreshScopeListText();
// Re-notify the command in case the button was bound
// before Load finished and the CanExecute cache is
// stale.
SaveCommand.NotifyCanExecuteChanged();
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -296,30 +382,63 @@ public partial class Settings : ObservableObject
} }
/// <summary> /// <summary>
/// Marshals every <see cref="ObservableObject.PropertyChanged"/> /// Persist the current in-memory state to
/// notification onto the Avalonia UI thread before it leaves this /// <c>~/.config/PostIt/postit-settings.json</c> (Linux) /
/// instance. Without this, a background worker (OIDC discovery /// equivalent <c>%APPDATA%\PostIt\postit-settings.json</c>
/// running on a Task, the file I/O continuation in <see cref="Load"/>, /// (Windows). Symmetrical to <see cref="Load"/>: same path,
/// any HTTP callback) would raise <c>PropertyChanged</c> from a /// same directory creation, same <c>0600</c> file mode (POSIX)
/// thread-pool thread and Avalonia's binding sink would then reach /// as <c>TokenStore.Save</c>. Clears <see cref="IsDirty"/>
/// into <c>DataValidationErrors.SetErrors</c> from off-thread, /// on success.
/// blowing up with <c>InvalidOperationException: The calling thread ///
/// cannot access this object because a different thread owns it</c>. /// <para>Synchronous on purpose: matches <see cref="Load"/>'s
/// We keep the mutation lock separate (above) and let the property /// contract (the file is a few KiB at most, and the Avalonia
/// setters do their work synchronously — only the notification /// UI thread cannot await here without risking the same
/// fan-out is bounced to the UI thread. /// deadlock <see cref="Load"/>'s docstring describes).
/// </para>
/// </summary> /// </summary>
protected override void OnPropertyChanged(System.ComponentModel.PropertyChangedEventArgs e) [RelayCommand(CanExecute = nameof(CanSave))]
public void Save()
{ {
if (UiDispatcher.IsOnUiThread) var configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
Directory.CreateDirectory(configDir);
var configPath = Path.Combine(configDir, SettingsFileName);
lock (_mutationGate)
{ {
base.OnPropertyChanged(e); try
return; {
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true,
});
File.WriteAllText(configPath, json);
if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
File.SetUnixFileMode(configPath,
UnixFileMode.UserRead | UnixFileMode.UserWrite);
IsDirty = false;
Console.WriteLine($"💾 Settings saved to {configPath}");
}
catch (Exception ex)
{
Console.Error.WriteLine($"🩎 Error saving settings to {configPath}: {ex.Message}");
throw;
}
} }
// 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));
} }
private bool CanSave() => IsDirty;
/// <summary>
/// Re-notify the <c>SaveCommand</c> (generated by
/// <c>[RelayCommand]</c> on <see cref="Save"/>) so XAML
/// re-evaluates <c>CanExecute</c> when the dirty flag flips
/// outside the scope of a direct save (e.g. on <see cref="Load"/>
/// / <see cref="ApplyJson"/>).
/// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
} }

View file

@ -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(); }
}

View file

@ -9,8 +9,5 @@
FontSize="22" FontSize="22"
FontWeight="SemiBold" FontWeight="SemiBold"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
<Button Content="Login"
Click="OnLoginClick"
HorizontalAlignment="Center"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -1,9 +1,4 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -13,26 +8,4 @@ public partial class HomePage : ContentPage
{ {
InitializeComponent(); InitializeComponent();
} }
private void OnLoginClick(object? sender, RoutedEventArgs e)
{
var vm = (HomePageViewModel)DataContext!;
// Resolve the next view-model from the same DI container that
// produced vm.Settings. Constructing them with `new` would
// instantiate a second Settings and reintroduce the
// postit://callback crash we just fixed in Settings.cs.
var services = (App.Current as App)?.Services
?? throw new System.InvalidOperationException(
"App.Services is not bound. OnFrameworkInitializationCompleted must run before any view handler.");
var loginVm = services.GetRequiredService<LoginPageViewModel>();
loginVm.LoginSucceeded += () =>
{
var client = services.GetRequiredService<BlogApiClient>();
Navigation?.PushAsync(new MainPage
{
DataContext = services.GetRequiredService<MainPageViewModel>()
});
};
Navigation?.PushAsync(new LoginPage { DataContext = loginVm });
}
} }

View file

@ -1,75 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.LoginPage"
x:DataType="vm:LoginPageViewModel"
Header="Login">
<Design.DataContext>
<vm:MainPageViewModel />
</Design.DataContext>
<StackPanel HorizontalAlignment="Stretch"
VerticalAlignment="Center"
Spacing="20">
<Border IsVisible="{Binding ConfigMissing}"
Background="#FFF3CD"
BorderBrush="#E0A800"
BorderThickness="1"
CornerRadius="4"
Padding="10">
<TextBlock Text="{Binding ConfigMissingMessage}"
TextWrapping="Wrap"
Foreground="#7A5800"/>
</Border>
<TextBlock Text="Sign In"
FontSize="24"
HorizontalAlignment="Center"/>
<Button Content="Login"
Command="{Binding LoginAsync}"/>
<Button Content="Cancel"
Click="OnCancelClick"/>
<Button Content="Register a new account"
IsEnabled="{Binding HasRegisterUrl}"
Click="OnRegisterClick"/>
<Button Content="Forgot password?"
IsEnabled="{Binding HasForgotPasswordUrl}"
Click="OnForgotPasswordClick"/>
<TextBox Name="StatusText"
Text="{Binding StatusMessage}"
TextWrapping="Wrap"
IsReadOnly="True"
BorderThickness="0"
Background="Transparent"/>
<!--
Phase indicator: a single-line label bound to the OIDC flow
phase (Discovering / OpeningBrowser / AwaitingCallback / …).
Operators use this to debug the postit:// callback hand-off:
if AwaitingCallback never advances to ExchangingCode, the OS
never re-launched PostIt with the callback URL. Kept as a
discrete control (not folded into StatusMessage) so the phase
always renders even when StatusMessage is empty or stale.
-->
<Border Background="#EEF2F7"
BorderBrush="#B0BEC5"
BorderThickness="1"
CornerRadius="4"
Padding="6,4">
<TextBlock Text="{Binding PhaseLabel}"
FontWeight="SemiBold"
Foreground="#37474F"/>
</Border>
<ProgressBar IsIndeterminate="{Binding IsBusy}" />
</StackPanel>
</ContentPage>

View file

@ -1,66 +0,0 @@
using System;
using System.Diagnostics;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class LoginPage : ContentPage
{
public LoginPage()
{
InitializeComponent();
// HomePage pushes LoginPage via PushModalAsync(new LoginPage())
// without supplying a DataContext. Attach a freshly-built
// LoginPageViewModel whenever the caller hasn't wired one up,
// so XAML bindings and LoginAsyncCommand resolve. We resolve
// through the DI container (not `new LoginPageViewModel()`)
// so the LoginPageViewModel shares the canonical Settings
// singleton with the rest of the app — constructing a fresh
// VM here was the original source of the two-Settings
// postit://callback crash.
if (DataContext is null)
{
var services = (App.Current as App)?.Services
?? throw new InvalidOperationException(
"App.Services is not bound. OnFrameworkInitializationCompleted must run before any view handler.");
DataContext = services.GetRequiredService<LoginPageViewModel>();
}
}
private async void OnCancelClick(object? sender, RoutedEventArgs e)
{
// Cancel button dismisses all open modals
if (Navigation is not null)
await Navigation.PopAllModalsAsync();
}
private void OnRegisterClick(object? sender, RoutedEventArgs e)
{
OpenExternalUrl((DataContext as LoginPageViewModel)?.RegisterUrl);
}
private void OnForgotPasswordClick(object? sender, RoutedEventArgs e)
{
OpenExternalUrl((DataContext as LoginPageViewModel)?.ForgotPasswordUrl);
}
private static void OpenExternalUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return;
// Desktop launcher: shell-execute the URL so the OS picks the right handler.
// Platform projects (PostIt.Android, PostIt.Browser) override this behavior
// when they plug into the LoginPage lifecycle.
try
{
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
catch (Exception ex)
{
Debug.WriteLine($"Failed to open external URL {url}: {ex.Message}");
}
}
}

View file

@ -79,10 +79,9 @@
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" /> <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox Grid.Row="1" Text="{Binding SelectedPost.Title, Mode=TwoWay}" PlaceholderText="Title" /> <TextBox Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" Text="{Binding SelectedPost.AuthorId, Mode=TwoWay}" PlaceholderText="Author id" /> <AvaloniaEdit:TextEditor Grid.Row="2"
<AvaloniaEdit:TextEditor Grid.Row="3" views:TextEditorBinding.Text="{Binding DraftArticle, Mode=TwoWay}"
views:TextEditorBinding.Text="{Binding SelectedPost.Article, Mode=TwoWay}"
ShowLineNumbers="True" ShowLineNumbers="True"
FontFamily="Cascadia Code, Consolas, Menlo, Monospace" FontFamily="Cascadia Code, Consolas, Menlo, Monospace"
MinHeight="320" MinHeight="320"
@ -90,7 +89,7 @@
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
VerticalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" /> HorizontalScrollBarVisibility="Auto" />
<TextBlock Grid.Row="4" Text="{Binding StatusMessage}" Foreground="Gray" /> <TextBlock Grid.Row="3" Text="{Binding StatusMessage}" Foreground="Gray" />
</Grid> </Grid>
</Border> </Border>
</Grid> </Grid>

View file

@ -10,13 +10,13 @@
Root layout: persistent session banner on top, navigation Root layout: persistent session banner on top, navigation
surface below. The banner is the single source of truth for surface below. The banner is the single source of truth for
"Connecté / Déconnecté" and the logout button — visible on "Connecté / Déconnecté" and the logout button — visible on
every page (HomePage, LoginPage, MainPage) so the user never every page (HomePage, MainPage) so the user never has to dig
has to dig through a menu to find their session state. through a menu to find their session state.
The navigation stack is built programmatically in The navigation stack is built programmatically in
App.OnFrameworkInitializationCompleted: HomePage is the App.OnFrameworkInitializationCompleted: HomePage is the
root, MainPage is pushed on top when the silent refresh root, MainPage is pushed on top when the silent refresh
succeeds at boot. succeeds at boot or after a fresh login from HomePage.
--> -->
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<views:SessionStatusBanner x:Name="SessionBanner" <views:SessionStatusBanner x:Name="SessionBanner"

View file

@ -4,7 +4,6 @@
x:Class="PostIt.Views.SessionStatusBanner" x:Class="PostIt.Views.SessionStatusBanner"
x:DataType="vm:SessionStatusViewModel"> x:DataType="vm:SessionStatusViewModel">
<Border DockPanel.Dock="Top" <Border DockPanel.Dock="Top"
Background="#ECEFF1"
BorderBrush="#B0BEC5" BorderBrush="#B0BEC5"
BorderThickness="0,0,0,1" BorderThickness="0,0,0,1"
Padding="12,6"> Padding="12,6">
@ -17,6 +16,13 @@
Command="{Binding LogoutAsync}" Command="{Binding LogoutAsync}"
IsVisible="{Binding IsLoggedIn}" IsVisible="{Binding IsLoggedIn}"
DockPanel.Dock="Right"/> DockPanel.Dock="Right"/>
<Button Content="Se connecter"
Command="{Binding LoginCommand}"
IsVisible="{Binding IsLoggedOut}"
DockPanel.Dock="Right"/>
<Button Content="Paramètres"
Command="{Binding OpenSettingsCommand}"
DockPanel.Dock="Right"/>
</DockPanel> </DockPanel>
</Border> </Border>
</UserControl> </UserControl>

View file

@ -4,21 +4,60 @@
xmlns:controls="cl:avalonia.Controls" xmlns:controls="cl:avalonia.Controls"
x:Class="PostIt.Views.SettingsPage" x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:SettingsPageViewModel" x:DataType="vm:Settings"
Width="400" >
Height="300">
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Authority"/> <TextBlock Grid.Row="0" Text="Authority"/>
<TextBox Grid.Row="1" x:Name="AuthorityTextBox" Text="{Binding Authority, Mode=TwoWay}"/> <TextBox Grid.Row="1" x:Name="AuthorityTextBox"
Text="{Binding Authentication.Authority, Mode=TwoWay}"/>
<TextBlock Grid.Row="2" Text="ClientId"/> <TextBlock Grid.Row="2" Text="ClientId"/>
<TextBox Grid.Row="3" x:Name="ClientIdTextBox" Text="{Binding ClientId, Mode=TwoWay}"/> <TextBox Grid.Row="3" x:Name="ClientIdTextBox"
Text="{Binding Authentication.ClientId, Mode=TwoWay}"/>
<TextBlock Grid.Row="4" Text="Scopes (space-separated)"/>
<TextBox Grid.Row="5" x:Name="ScopesTextBox"
Text="{Binding Authentication.ScopeListText, Mode=TwoWay}"/>
<TextBlock Grid.Row="6" Text="Blogs API URL"/>
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver"
HorizontalAlignment="Right"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -0,0 +1,42 @@
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// 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.
/// </summary>
public sealed class BlogApiSmokeTests : IClassFixture<BlogsWebServerFixture>
{
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);
}
}

View file

@ -0,0 +1,330 @@
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;
/// <summary>
/// Behavioural tests for <c>BlogApiController</c>. Built on the
/// <see cref="BlogsWebServerFixture"/> scaffold: in-memory
/// <c>ApplicationDbContext</c>, real <c>BlogSpotService</c>, and a
/// real <c>AddJwtBearer</c> validating HS256 tokens signed by
/// <see cref="TestTokenIssuer"/>. The production <c>BlogScope</c>
/// policy runs unmodified — sending <c>Authorization: Bearer …</c>
/// 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.
/// </summary>
public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>The fixture's <c>WebApplication</c> is bound to
/// <c>https://localhost:&lt;random&gt;</c> via
/// <see cref="WebHostFixture.Addresses"/>. We pick the first
/// https URL and append the controller route
/// (<c>/api/v1/blog</c>, matching the production
/// <c>[Route(APIPrefix + "/blog")]</c>).</summary>
private string BlogsUrl =>
_fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog";
/// <summary>Build an authenticated client: a real
/// <c>Authorization: Bearer &lt;jwt&gt;</c> header where the JWT
/// is signed by <see cref="TestTokenIssuer"/> and carries
/// <c>sub = subject</c>. The production <c>BlogScope</c> policy
/// reads <c>scope=blogs</c> off the same token, so
/// <c>TestTokenIssuer.Issue</c>'s default scope is enough.</summary>
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
// 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.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", TestTokenIssuer.Issue(subject));
return http;
}
/// <summary>Build an unauthenticated client. Used to assert that
/// the <c>BlogScope</c> policy fails closed when no bearer
/// token is presented.</summary>
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()
{
ResetDatabase();
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());
}
[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<BlogPost>();
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());
}
[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<BlogPost>())!;
// 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<BlogPost>())!;
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());
}
[Fact]
public async Task PostBlog_from_PostIt_shape_returns_201_not_400()
{
// Regression test for the "Save" button in PostIt: from the
// user's point of view, they type a Title and an Article in
// the editor pane and tap "Save". The VM serialises the
// SelectedPost via JsonContent.Create (camelCase, System.Text.Json
// defaults) and POSTs it to /api/v1/blog. This test sends
// exactly that payload — same fields, same types, same
// serialiser (PostAsJsonAsync is wired to the same
// System.Net.Http.Json pipeline that YavscApiClient uses on
// the PostIt side) — and asserts that the server accepts it
// with 201 Created, not 400 BadRequest. If the controller's
// ModelState validation starts rejecting the PostIt payload
// (missing field, wrong casing, etc.), this test fails
// before the regression reaches a user.
ResetDatabase();
using var http = NewClient(subject: "tester");
// Mirrors what MainPageViewModel.Save builds: a BlogPost with
// Id=0 (so the controller treats it as a create), Title and
// Article filled in by the user, and DateCreated/DateModified
// stamped by the VM. AuthorId is what the OIDC sub resolves
// to in the test fixture.
var draft = new BlogPost
{
Id = 0,
Title = "Mon premier billet",
AuthorId = "tester",
Article = "Contenu du billet de test.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
// Dump the body on failure so the test name + the response
// payload are enough to start a fix; the framework's
// assertion message is otherwise opaque (just "Expected
// Created, got BadRequest").
if (response.StatusCode != HttpStatusCode.Created)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Fail(string.Format("Expected 201 Created, got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
}
}
[Fact]
public async Task PostBlog_with_empty_title_returns_400()
{
// Mirrors the buggy branch in MainPageViewModel.Save: when
// the user taps "Save" without a SelectedPost (e.g. they
// typed into the editor without first clicking an item in
// the list, so the {Binding SelectedPost.Title, Mode=TwoWay}
// XAML binding had no target and the keystrokes were
// silently dropped), the VM builds a BlogPost with
// Title = string.Empty and POSTs it. BlogPost.Title carries
// [Required] → ModelState.IsValid fails → 400 BadRequest.
// This is the regression we are hunting. The 400 is
// expected here: the test pins the *current* controller
// behaviour so a future change that, say, makes Title
// nullable in the model or drops [Required], triggers a
// conscious update of the test (and probably of the VM).
ResetDatabase();
using var http = NewClient(subject: "tester");
var draft = new BlogPost
{
Id = 0,
Title = string.Empty,
AuthorId = "tester",
Article = "Article non vide, mais titre vide.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow
};
var response = await http.PostAsJsonAsync("/api/v1/blog", draft);
if (response.StatusCode != HttpStatusCode.BadRequest)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Fail(string.Format("Expected 400 BadRequest (empty Title is invalid), got {0} {1}. Body: {2}", (int)response.StatusCode, response.StatusCode, body));
}
}
}

View file

@ -0,0 +1,175 @@
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;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Test host for the Yavsc.Blogs API surface. Specialisation of
/// <see cref="WebHostFixture"/> that wires up only the bits the
/// blog API actually depends on:
///
/// <list type="bullet">
/// <item><description>An in-memory <see cref="ApplicationDbContext"/>
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run
/// against an empty table and return an empty list.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: the GET index path doesn't read the file system, so any
/// implementation is fine.</description></item>
/// <item><description>The real <c>BlogSpotService</c>, which calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission())</c>
/// on PUT. The fixture registers the real
/// <see cref="PermissionHandler"/> so the resource-based ownership
/// check runs end-to-end; tests that want a 204 PUT must sign a
/// JWT whose <c>sub</c> matches the post's <c>AuthorId</c>.</description></item>
/// <item><description>A real <c>AddJwtBearer</c> with HS256,
/// sharing its <see cref="TestTokenIssuer.SigningKey"/> 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.</description></item>
/// <item><description>The production <c>BlogScope</c> policy
/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs"))
/// registered verbatim. Tests that omit the bearer header exercise
/// the unauthenticated path and get 401.</description></item>
/// </list>
///
/// 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.
/// </summary>
public sealed class BlogsWebServerFixture : WebHostFixture
{
private InMemoryDatabaseRoot? _inMemoryRoot;
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.
//
// 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<ApplicationDbContext>(opt =>
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.
builder.Services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// Real BlogSpotService — same instance the production host
// builds (ApplicationDbContext, IAuthorizationService,
// 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<BlogSpotService>();
// 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<IAuthorizationHandler, PermissionHandler>();
// 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 =>
{
opt.AddPolicy("BlogScope", policy =>
{
policy.RequireAuthenticatedUser()
.RequireClaim("scope", "blogs");
});
});
// 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();
}
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await Task.CompletedTask;
return app;
}
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
/// blog API endpoints exercised by the first tests don't read the
/// file system, so the implementation can be a no-op.</summary>
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
}

View file

@ -0,0 +1,7 @@
<Project>
<!--
Yavsc.Blogs.Tests has no project-specific package versions. All
package versions are declared at the repository root.
-->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

View file

@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Blogs.Tests</RootNamespace>
<UserSecretsId>b1a9d0d6-3f5e-4a07-9f0a-7e4d5b6c1a82</UserSecretsId>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
<InformationalVersion>1.0.1-5+Branch.main.Sha.0617fc6bda7151c70559d87177e2dcfb1b60995f</InformationalVersion>
<Version>1.0.1-5</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Blogs\Yavsc.Blogs.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitVersion.MsBuild" />
</ItemGroup>
</Project>

View file

@ -92,7 +92,29 @@ namespace Yavsc.Blogs.Controllers
return BadRequest(ModelState); 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); return CreatedAtRoute("GetBlog", new { id = post.Id }, post);
} }

View file

@ -19,7 +19,6 @@ internal class Program
Console.Title = "Yavsc.Blogs"; Console.Title = "Yavsc.Blogs";
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.AddConfiguration("blogs"); builder.AddConfiguration("blogs");
var services = builder.Services; var services = builder.Services;
@ -37,10 +36,23 @@ internal class Program
}) })
.AddYavscCors(builder.Configuration) .AddYavscCors(builder.Configuration)
.AddControllers(); .AddControllers();
String authority = builder.Configuration.GetValue<string>("Site:Authority");
String audience = builder.Configuration.GetValue<string>("Site:Audience");
if (string.IsNullOrEmpty(authority))
{
throw new Exception("Site:Authority is not configured in appsettings.json");
}
// AuthenticationBuilder // AuthenticationBuilder
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration); .AddYavscJwtBearer(builder.Configuration,
options =>
{
options.Authority = authority;
options.Audience = builder.Configuration.GetValue<string>
("Site:Audience");
});
// DbContextBuilder // DbContextBuilder
services.AddDbContext<ApplicationDbContext>(options => services.AddDbContext<ApplicationDbContext>(options =>
@ -81,18 +93,20 @@ internal class Program
{ {
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
app app
.UseRouting() .UseRouting()
.UseAuthentication() .UseAuthentication()
.UseAuthorization() .UseAuthorization()
.UseCors("default") .UseCors("default")
; ;
app.MapIdentityApi<ApplicationUser>().RequireAuthorization("BlogScope"); app.Services.GetRequiredService<ILoggerFactory>().CreateLogger("Program")
.LogInformation($"Yavsc.Blogs started, Authority is '{authority}', Audience is '{audience}'");
app.MapGet("/identity", (HttpContext context) => app.MapControllers();
new JsonResult(context?.User?.Claims.Select(c => new { c.Type, c.Value })) app.MapIdentityApi<ApplicationUser>().RequireAuthorization("BlogScope")
); .WithHttpLogging(Microsoft.AspNetCore.HttpLogging.HttpLoggingFields.All)
.WithTags("Identity")
.WithDescription("Identity API for Yavsc.Blogs")
.WithMetadata(new ApiExplorerSettingsAttribute { GroupName = "Identity" });
app.UseSession(); app.UseSession();
await app.RunAsync(); await app.RunAsync();

View file

@ -8,12 +8,26 @@
"https://localhost:5005" "https://localhost:5005"
] ]
}, },
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": { "Logging": {
"LogLevel": { "LogLevel": {
"Default": "Information", "Default": "Information",
"Microsoft": "Warning", "Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information" "Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:5002"
},
"Https": {
"Url": "https://localhost:5003"
}
}
}
} }

View file

@ -0,0 +1,165 @@
using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Xunit;
using Yavsc.Extensions;
namespace Yavsc.Org.Tests;
/// <summary>
/// Tests for <see cref="HostingExtensions.ComputeKid"/>, the
/// helper that derives the JWT <c>kid</c> header / JWKS key id
/// from the signing certificate. The kid is consumed by every
/// resource server (Yavsc.Blogs, Yavsc.Api) to match a token to
/// the right key in the JWKS, so getting its shape and stability
/// right is the whole point of the fix in commit 2c6d1157
/// (IDX10500 regression).
/// </summary>
/// <remarks>
/// We don't load the production cert (Let's Encrypt PEM + RSA
/// private key) — we generate throwaway self-signed certs in a
/// temp dir. The contract under test is the truncation /
/// encoding of the thumbprint, which is independent of the key
/// type and the cert issuer.
/// </remarks>
public class ComputeKidTests : IDisposable
{
private readonly string _tempDir;
public ComputeKidTests()
{
_tempDir = Path.Combine(
Path.GetTempPath(),
"yavsc-compute-kid-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
try { Directory.Delete(_tempDir, recursive: true); }
catch { /* best effort — Temp gets cleaned eventually */ }
}
[Fact]
public void ComputeKid_returns_first_16_hex_chars_of_cert_thumbprint()
{
var certPath = WriteSelfSignedCertRsa(out var expectedThumbHex);
var kid = HostingExtensions.ComputeKid(certPath);
// 16 hex chars = 64 bits, enough to be globally unique
// within a deployment and compact enough for a JWT header.
Assert.Equal(16, kid.Length);
Assert.True(
kid.All(c => "0123456789ABCDEF".Contains(c)),
$"kid '{kid}' contains non-uppercase-hex characters");
// Match the first 16 chars of the thumbprint exactly. We
// compute the expected value from the same cert the helper
// was given — no magic constants, no copy-paste of the
// truncation logic under test.
Assert.Equal(expectedThumbHex[..16], kid);
}
[Fact]
public void ComputeKid_is_stable_across_repeated_reads()
{
var certPath = WriteSelfSignedCertRsa(out _);
var first = HostingExtensions.ComputeKid(certPath);
var second = HostingExtensions.ComputeKid(certPath);
var third = HostingExtensions.ComputeKid(certPath);
// Stability matters: a non-deterministic kid would
// invalidate tokens on every IdentityServer restart.
Assert.Equal(first, second);
Assert.Equal(second, third);
}
[Fact]
public void ComputeKid_differs_between_distinct_certificates()
{
var certPathA = WriteSelfSignedCertRsa(out _);
var certPathB = WriteSelfSignedCertRsa(out _);
var kidA = HostingExtensions.ComputeKid(certPathA);
var kidB = HostingExtensions.ComputeKid(certPathB);
// Two independent RNG-drawn RSA keys will (in practice
// always) yield different thumbprints. A 64-bit truncated
// space has collisions at ~2^32 certs; we won't get there.
Assert.NotEqual(kidA, kidB);
}
[Fact]
public void ComputeKid_uses_thumbprint_not_subject_or_serial()
{
// The previous fix-message claimed SHA-256; the helper
// actually reads X509Certificate2.GetCertHash() which is
// SHA-1. Pin that behaviour so a future refactor that
// switches to SHA-256 (or any other digest) is forced to
// update the test deliberately.
var certPath = WriteSelfSignedCertRsa(out var thumbHex);
var kid = HostingExtensions.ComputeKid(certPath);
// 16 hex chars is half of a 20-byte SHA-1 thumbprint.
// SHA-256 would be 32 bytes (64 hex chars) before
// truncation; SHA-1 is the only common digest whose
// hex encoding fits the 16-char prefix we observe.
Assert.Equal(20, thumbHex.Length / 2);
Assert.Equal(thumbHex[..16], kid);
}
[Fact]
public void ComputeKid_propagates_cryptographic_exception_for_missing_file()
{
// The wrapper LoadSigningCredentials wraps this in an
// InvalidOperationException, but ComputeKid itself is a
// plain helper — it must surface the parser error so the
// wrapper can attach the cert path to the message. We
// assert against the base CryptographicException rather
// than the concrete subtype because the runtime picks
// different leaf types per platform (on Linux/OpenSSL we
// get Interop+Crypto+OpenSslCryptographicException, on
// Windows we'd get the older CryptographicException
// directly); the contract is the same either way.
var missing = Path.Combine(_tempDir, "does-not-exist.pem");
Assert.ThrowsAny<CryptographicException>(
() => HostingExtensions.ComputeKid(missing));
}
// --- helpers ----------------------------------------------------
/// <summary>
/// Generate a throwaway self-signed RSA-2048 cert, export it
/// as PEM to a file inside the test temp dir, and return the
/// path. The out parameter receives the upper-case hex form
/// of the cert's SHA-1 thumbprint so tests can pin the
/// expected kid without re-implementing the helper.
/// </summary>
private string WriteSelfSignedCertRsa(out string thumbHex)
{
using var rsa = RSA.Create(2048);
var req = new CertificateRequest(
"CN=yavsc-test",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
using var cert = req.CreateSelfSigned(
DateTimeOffset.UtcNow.AddDays(-1),
DateTimeOffset.UtcNow.AddYears(1));
// Capture the thumbprint before exporting — the cert is
// disposed by `using` and the exported PEM is what the
// helper will read.
thumbHex = Convert.ToHexString(cert.GetCertHash());
var path = Path.Combine(_tempDir, "cert-" + Guid.NewGuid().ToString("N") + ".pem");
File.WriteAllText(path, cert.ExportCertificatePem());
return path;
}
}

View file

@ -1,3 +1,9 @@
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using System.Runtime.CompilerServices;
[assembly: RootNamespace("Yavsc")] [assembly: RootNamespace("Yavsc")]
// Expose internals to the Yavsc.Org.Tests project so unit tests can
// reach the signing-credential loader (LoadSigningCredentials / kid
// derivation) without going through the full IdentityServer boot.
[assembly: InternalsVisibleTo("Yavsc.Org.Tests")]

View file

@ -478,6 +478,20 @@ public static class HostingExtensions
// Validate the cert is readable (used downstream for token // Validate the cert is readable (used downstream for token
// audience/subject validation; signing itself uses the key). // audience/subject validation; signing itself uses the key).
// Derive a stable KeyId from the certificate's SHA-1
// thumbprint (the default for X509Certificate2.GetCertHash()).
// Without an explicit KeyId, IdentityServer emits JWTs without
// a 'kid' header and the JWKS without per-key identifiers,
// which breaks signature validation on resource servers (they
// cannot match a token to a key in the JWKS, they fail with
// IDX10500 "The signature key was not found"). Truncating the
// 40-hex-char SHA-1 to 16 hex chars is enough to be globally
// unique within a deployment and keeps the JWT header compact.
// The thumbprint changes on cert renewal, which is the desired
// behaviour: old tokens age out, resource servers refresh
// their JWKS cache for the new kid.
var kid = ComputeKid(certPath);
string keyPem = File.ReadAllText(keyPath); string keyPem = File.ReadAllText(keyPath);
// BouncyCastle's PemReader accepts every flavour of unencrypted // BouncyCastle's PemReader accepts every flavour of unencrypted
@ -513,7 +527,7 @@ public static class HostingExtensions
#pragma warning disable CA1416 // Valider la compatibilité de la plateforme #pragma warning disable CA1416 // Valider la compatibilité de la plateforme
var rsaDotNet = DotNetUtilities.ToRSA(rsa); var rsaDotNet = DotNetUtilities.ToRSA(rsa);
#pragma warning restore CA1416 // Valider la compatibilité de la plateforme #pragma warning restore CA1416 // Valider la compatibilité de la plateforme
var key = new RsaSecurityKey(rsaDotNet); var key = new RsaSecurityKey(rsaDotNet) { KeyId = kid };
return new SigningCredentials(key, SecurityAlgorithms.RsaSha256); return new SigningCredentials(key, SecurityAlgorithms.RsaSha256);
} }
case ECPrivateKeyParameters ec: case ECPrivateKeyParameters ec:
@ -525,7 +539,7 @@ public static class HostingExtensions
}; };
var ecdsa = ECDsa.Create(); var ecdsa = ECDsa.Create();
ecdsa.ImportParameters(ecParams); ecdsa.ImportParameters(ecParams);
var key = new ECDsaSecurityKey(ecdsa); var key = new ECDsaSecurityKey(ecdsa) { KeyId = kid };
return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256); return new SigningCredentials(key, SecurityAlgorithms.EcdsaSha256);
} }
default: default:
@ -535,6 +549,34 @@ public static class HostingExtensions
} }
} }
/// <summary>
/// Derive the <c>kid</c> used to identify the signing key in the
/// JWT header and the JWKS. Takes the first 16 hex characters of
/// the certificate's SHA-1 thumbprint. See the inline rationale in
/// <see cref="LoadSigningCredentialsInner"/> for why this is
/// needed (IdentityServer8 + IDX10500).
/// </summary>
/// <remarks>
/// Internal so unit tests in <c>Yavsc.Org.Tests</c> can exercise
/// the truncation/encoding without going through the full PEM /
/// BouncyCastle pipeline. The input is a path rather than a
/// pre-loaded <see cref="X509Certificate2"/> to match the
/// production call site.
/// </remarks>
internal static string ComputeKid(string certPath)
{
// X509CertificateLoader is the .NET 9+ replacement for the
// obsolete `new X509Certificate2(string)` ctor (SYSLIB0057).
// Same on-disk format (PEM or DER), same thumbprint, just
// doesn't trip the obsolete-API warning at build time.
var certForKid = X509CertificateLoader.LoadCertificateFromFile(certPath);
var certHash = certForKid.GetCertHash();
// GetCertHash() returns a SHA-1 thumbprint (20 bytes, 40 hex
// chars). Truncating to 16 hex chars keeps the JWT header
// compact; Math.Min guards against an unexpected short hash.
return Convert.ToHexString(certHash)[..Math.Min(16, certHash.Length * 2)];
}
/// <summary> /// <summary>
/// Map a BouncyCastle <see cref="ECDomainParameters"/> to a /// Map a BouncyCastle <see cref="ECDomainParameters"/> to a
/// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/> /// <see cref="ECCurve"/> that <see cref="ECDsa.ImportParameters"/>

View file

@ -91,7 +91,31 @@ public static class ServiceExtensions
RoleClaimType = YavscConstants.RoleClaimType RoleClaimType = YavscConstants.RoleClaimType
}; };
options.MapInboundClaims = true; options.MapInboundClaims = true;
// Dev: every Yavsc resource service (Yavsc.Api, Yavsc.Blogs,
// Yavsc.Org itself) validates JWTs against the OP that runs
// on https://localhost:5001 with a self-signed dev cert.
// The default .NET HttpClient rejects self-signed certs, so
// JwtBearer's backchannel silently fails to fetch the OIDC
// discovery + JWKS. With an empty ValidIssuer, every token
// is rejected with IDX10204 ("ValidIssuer is null or
// whitespace"). Telling the backchannel to skip TLS
// validation unblocks discovery in dev. Production uses a
// real CA-signed cert and the default validation path; the
// override is gated on HostingEnvironment == Development
// and only fires when the consumer opt-in via the
// 'Yavsc:Dev:TlsInsecure' configuration flag (default
// false), so a misconfigured production environment cannot
// silently downgrade TLS.
if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development")
{
options.BackchannelHttpHandler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback =
(_, _, _, _) => true
};
}
configure?.Invoke(options); configure?.Invoke(options);
}); });
} }
} }

View file

@ -0,0 +1,107 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace Yavsc.Tests.Shared;
/// <summary>
/// Mints HS256-signed JWTs for integration tests. The signing key is
/// held in a static field shared with the test host's
/// <c>AddJwtBearer</c> registration: whatever the host validates
/// against, this issuer signs with.
///
/// <para>
/// 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
/// <c>AddYavscJwtBearer</c> — this issuer is *only* for the
/// in-process test host.
/// </para>
/// </summary>
public static class TestTokenIssuer
{
/// <summary>
/// Symmetric signing key shared with the test host's
/// <c>TokenValidationParameters.IssuerSigningKey</c>.
/// 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).
/// </summary>
public static readonly SymmetricSecurityKey SigningKey =
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32)));
/// <summary>
/// Issuer stamped into the <c>iss</c> claim and checked by the
/// test host. Must match
/// <c>TokenValidationParameters.ValidIssuer</c>.
/// </summary>
public const string Issuer = "yavsc-test-issuer";
/// <summary>
/// Audience stamped into the <c>aud</c> claim. The test host
/// does not validate audience (production may), so this is here
/// for shape only.
/// </summary>
public const string Audience = "yavsc-test";
private static bool _inboundClaimTypeMapCleared;
/// <summary>
/// Mint a JWT carrying the given <paramref name="subject"/> as
/// the <c>sub</c> claim, a single <c>scope</c> claim with value
/// <paramref name="scope"/>, and any additional
/// <paramref name="extraClaims"/>. Token is valid for one hour
/// from now.
/// </summary>
/// <param name="subject">Value of the <c>sub</c> claim. Read
/// back by <c>UserHelpers.GetUserId</c>, which is how
/// <c>PermissionHandler.IsOwner</c> identifies the author of a
/// <c>BlogPost</c> on PUT.</param>
/// <param name="scope">Value of the <c>scope</c> claim. The
/// production <c>BlogScope</c> policy requires
/// <c>RequireClaim("scope", "blogs")</c>.</param>
/// <param name="extraClaims">Optional additional claims
/// (e.g. a role for an admin-bypass test).</param>
public static string Issue(
string subject,
string scope = "blogs",
IEnumerable<Claim>? extraClaims = null)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
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);
}
}

View file

@ -13,9 +13,10 @@
inherit from the shared base classes. inherit from the shared base classes.
--> -->
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Hosting" /> <PackageReference Include="Microsoft.AspNetCore.Hosting" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -33,6 +33,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Desktop", "src\PostI
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt.Tests\PostIt.Tests.csproj", "{4D283324-6DD3-4CD1-9893-8C317772C6B5}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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|x64.Build.0 = Release|Any CPU
{4D283324-6DD3-4CD1-9893-8C317772C6B5}.Release|x86.ActiveCfg = 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 {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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -205,5 +233,7 @@ Global
{AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE} {AF96C1C4-D128-4CD7-A8BB-D194E6D270F0} = {E13D107F-4053-D0DE-6394-453609595BFE}
{EFE24256-9335-44C5-8B77-E180C2DB3C0B} = {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} {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 EndGlobalSection
EndGlobal EndGlobal