Commit graph

3,345 commits

Author SHA1 Message Date
d58fad552a Test host: bind Kestrel to Site:Authority instead of dynamic port
The Yavsc.Org integration tests were failing 'Internal Server Error' on
the OIDC discovery document when run as part of the full test suite.

Root cause: WebHostFixture bound Kestrel to IPAddress.Loopback on a
dynamically-allocated port and exposed it via IServerAddressesFeature.
But the OIDC issuer URLs (and the issuer claim) come from Site:Authority,
which was left at the production value (mercure.pschneider.fr). So
IdentityServer8's discovery document advertised URLs unreachable from
the test process, and the discovery call returned a 500.

Fix:
  - WebServerFixture now overrides Site:Authority and Site:ExternalUrl
    in AddInMemoryCollection to 'https://localhost:44300' (the ASP.NET
    Core dev HTTPS convention).
  - WebHostFixture reads Site:Authority from configuration and binds
    Kestrel to that fixed URL. The exposed Addresses list is sourced
    from the same configuration value instead of the
    IServerAddressesFeature, so the listen URL and the OIDC issuer
    URLs always match.

Remoting.cs (Mandatory/Remoting.cs): add 'using
Microsoft.Extensions.DependencyInjection;' so the existing OIDC/DB
diagnostic block (capture raw HTTP response + dump OIDC-related DB
state on discovery failure) compiles. The diagnostic itself is left
in place — it's what surfaced the 500 in the first place.
2026-07-12 03:43:22 +01:00
587fdd13c2 Merge pull request 'fix/issue-3-splitquery' (#5) from fix/issue-3-splitquery into main
Reviewed-on: #5
2026-07-12 02:47:15 +01:00
3febd63b06 WIP audiences 2026-07-12 02:42:13 +01:00
d80fd598f5 ApplicationDbContext: drop redundant HasOne on 3 Client navs
LoadClientAsync(id) on ClientController used to trip an
IndexOutOfRangeException at the InMemory shaper for any
.Include() of one of three Client navs: RedirectUris,
AllowedScopes, AllowedGrantTypes. Five other Client navs (with
the same EF shape and the same application-level config) worked
fine.

Bisection pointed at the InMemory provider; that hypothesis was
wrong. The real cause is in ApplicationDbContext.OnModelCreating:
yavsc was redeclaring the HasOne<Client>().WithMany(...).
HasForeignKey(e => e.ClientId) for all eight Client* navs. The
same relation is already declared (more completely, with
.IsRequired().OnDelete(DeleteBehavior.Cascade)) by
IdentityServer8's ConfigureClientStore via ModelBuilderExtensions.

The redundant mapping on three specific entities — ClientScope,
ClientRedirectUri, ClientGrantType — interacts with the InMemory
provider's shaper in a way that throws IndexOutOfRange. Removing
the redundancy fixes it.

This commit also walks back e940a241:
- Drops .AsSplitQuery() from LoadClientAsync (no longer needed
  for the InMemory shaper, and the Postgres path it was a hedge
  against was a false alarm — there is no Postgres production
  bug here, only an InMemory shaper quirk surfaced by the
  redundant mapping).
- Removes the 9 Bisect_*_alone tests that were the artefact of
  the provider-hypothesis phase. They pointed at the right
  entities but for the wrong reason.
- Keeps EditRedirectUris_GET_after_add_lists_both_uris as the
  end-to-end regression sentinel: with the fix in place, it
  loads a Client with two RedirectUris and asserts both are
  rendered. Without the fix, it fails with IndexOutOfRange.
2026-07-12 02:39:13 +01:00
e940a241c9 ClientController: split LoadClientAsync into per-collection subqueries
LoadClientAsync chains 9 .Include() calls on dbContext.Clients.
On Postgres (and InMemory for some IdentityServer8 nav types), the
resulting cartesian product trips the query shaper with
IndexOutOfRangeException at IncludeCollection materialisation time.
Bug reproduces in production on the Blog admin pages that load a
Client by id.

AsSplitQuery() rewrites the load as 9 separate SELECTs joined by
client id, which sidesteps the cartesian explosion and any shaper
ambiguity between Claims/Properties/ClientSecrets (which share
Type/Value column names across some IdentityServer8 versions).

Tests:
- EditRedirectUris_GET_after_add_lists_both_uris: end-to-end
  reproducer that adds a second RedirectUri via POST then re-GETs
  the editor. Guards the fix on the integration path.
- Bisect_*_alone: nine unit tests that exercise the same
  .SingleOrDefaultAsync(c => c.Id == id).Include(nav) on the
  InMemory provider, one nav at a time. Pinpointed three
  problematic navs (RedirectUris, AllowedScopes, AllowedGrantTypes)
  on InMemory; kept as a regression net for any future shaper
  regressions on the InMemory provider (not the Postgres path).
2026-07-12 01:15:02 +01:00
c6714b4eeb Revert "repoduces the bug"
This reverts commit 93d625d270.
2026-07-11 22:17:58 +01:00
Lum
93d625d270 repoduces the bug 2026-07-11 21:56:25 +01:00
1d86a81fcb Tests: route Yavsc.Org test host through Testing environment
TestWebApplicationFactory used ASPNETCORE_ENVIRONMENT=Development, which
caused Program.Main's AddConfiguration("org") to load the tracked
appsettings-org.json (the reference file with the
'*** via dotnet user-secrets ou variable d'environnement ***'
placeholder connection string). Npgsql then failed to parse that
placeholder during host startup, failing six integration tests
(observed 2026-07-11: System.ArgumentException on
NpgsqlConnectionStringBuilder.set_Item).

Switching the test host to a dedicated Testing environment makes
AddConfiguration("org") pick up the new optional
appsettings-org.Testing.json file as the last source in the chain
(JSON → env vars), which overrides YavscConnection with the
InMemory marker and the Smtp section with the test stub values.

The .gitignore exception whitelists this file explicitly: it is a
configuration source for the test host, not a secrets file.

The WebServerFixture path is unchanged — it owns its
WebApplicationBuilder and adds the same in-memory override via its
BuildApp hook.
2026-07-11 20:50:39 +01:00
Lum
552e47c251 Blog: add test guarding the display-template fix + document test architecture
Le commit 2 a fixé la NPE du /BlogSpot/Details/{id} en passant
le display template par UserDisplayHelpers.AvatarSrc, qui
défend contre un UserName null. Ce commit complète le filet
de non-régression et pose la doc d'architecture des tests.

- ApplicationUserDisplayTemplateTests : assert que le cshtml ne
  concatène plus directement Model.UserName (ancien code fautif)
  et qu'il utilise bien le helper. Si quelqu'un revert la ligne
  4 du cshtml, les tests cassent. Les autres usages de
  Model.UserName (alt, title, asp-route-id) sont autorisés : ils
  ne sont pas la cause du 500, juste laids si null.
- doc/testing.md : vue d'ensemble de la stratégie de test
  (conventions NonRegression/Mandatory/Smoke/Controllers, EF
  in-memory via InMemoryDatabaseRoot partagé, auth stubs,
  quand ne pas écrire de test).
- src/Yavsc.Tests.Shared/README.md : détails du scaffold partagé
  (WebHostFixture + son cycle de vie et ses hooks,
  TestAuthPolicyProvider, TestTokenIssuer) et des deux
  spécialisations dans le repo
  (Yavsc.Org.Tests.WebServerFixture et
  Yavsc.Blogs.Tests.BlogsWebServerFixture).
- doc/README.md : entrée vers testing.md dans l'index.
2026-07-11 19:59:03 +01:00
Lum
4198e4dcf2 Blog: render user avatar through a null-safe helper
GET /BlogSpot/Details/1 retournait 500 (avec un 500-sur-500 sur
la page d'erreur elle-même) parce que DisplayTemplates
/ApplicationUser.cshtml faisait `var avuri = "/Avatars/" +
Model.UserName + ".s.png"` : avec <Nullable>enable</Nullable>,
Razor émet un null-check implicite sur Model.UserName et lève
NullReferenceException quand l'auteur n'a pas de UserName
posé (donnée héritée, user partiellement initialisé).

- UserDisplayHelpers.AvatarSrc : helper statique pur dans
  Yavsc.Abstract.Identity qui retourne
  YavscConstants.DefaultAvatar pour user null / UserName vide
  ou whitespace, et un path /avatars/<name>.s.png sinon.
- ApplicationUser.cshtml : utilise le helper.
- Tests : 4 cas (null, vide, whitespace, valide) dans
  Yavsc.Org.Tests/NonRegression.

Bonus : le path d'avatar passe de "/Avatars/" (S majuscule,
ne résolvait pas dans le middleware de fichiers statiques) à
YavscConstants.AvatarsPath ("/avatars" minuscule), pour fermer
l'autre trou que centraliser le calcul permettait de fixer
proprement.
2026-07-11 19:41:09 +01:00
Lum
51278dee31 Blog: enforce Restrict FK on BlogPost.Author and Comment.Author
Tout billet a un auteur, tout commentaire a un auteur. On aligne la
base sur ce contrat (Postgres) en droppant les orphelins existants
puis en remplaçant les FK en cascade par des FK Restrict.

- ApplicationDbContext: fluent pour BlogPost.Author et Comment.Author
  en DeleteBehavior.Restrict.
- ApplicationUser: ajoute la nav inverse BlogComments (manquait,
  EF aurait sinon créé une shadow FK).
- Migration 20260711173717_EnforceBlogAuthorFKs: Up purge les
  Comment/BlogSpot dont l'AuthorId n'existe plus, log le volume,
  puis drop+add des FK. Down laisse la cascade (état pré-migration).

Le code applicatif (BlogSpotService.Details) s'appuiera sur cette
contrainte dans un commit séparé.
2026-07-11 18:45:27 +01:00
6beff79e3e Merge commit 'f27f65c2d3' 2026-07-11 03:36:31 +01:00
f27f65c2d3 fixes the compile and timestamps to db 2026-07-11 03:26:13 +01:00
c8b98c7762 Merge pull request 'postIt' (#2) from postIt into main
Reviewed-on: #2
2026-07-11 02:57:28 +01:00
7f542b0777 Just post one, at least 1.0.1 2026-07-11 02:56:14 +01:00
8d61e73bfa 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
4aeba2ff42 Log the identity main params 2026-07-10 00:27:18 +01:00
959f4cd601 auto refresh token 2026-07-09 23:34:15 +01:00
9c38317178 using Material.Avalonia 2026-07-09 23:28:46 +01:00
1d313b760c 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
bf82362f9b 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
016b77af84 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
b2fb01512e fixes the button access 2026-07-09 21:35:06 +01:00
d804150d18 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
415a86ab4c 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 91516aee. 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
91516aee4a 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
9f2464f355 first, the blogs 2026-07-08 23:18:14 +01:00
d2b4e0a2c1 simpler 2026-07-08 23:16:16 +01:00
608f762b04 better 2026-07-08 23:16:04 +01:00
aecb83f6b6 GET posts [dev] 200 2026-07-08 22:46:46 +01:00
a3f56f5bb8 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
562a8bbd86 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
dd68ad993a 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
8af3d6c9ef 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
8cb1f41539 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
175b85d31c 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
3e30d12a5e 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
dee76bbeab 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
4674882077 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
e73afff34d 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
e8ada75bf3 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
b19aed9113 Merge pull request 'postIt' (#1) from postIt into main
Reviewed-on: #1
2026-07-06 21:35:47 +01:00
d530d697bb refactor: extract WebHostFixture + TestAuthPolicyProvider to shared lib
Yavsc.Blogs.Tests will need a fixture too. Lifting the cross-cutting
plumbing (Kestrel + self-signed cert + address discovery + lazy init)
into a new Yavsc.Tests.Shared project lets the next fixture inherit
from it without copying 200+ lines of setup boilerplate, and keeps
the Org.Tests fixture focused on its IdentityServer + SMTP seed.

* New project src/Yavsc.Tests.Shared with WebHostFixture (abstract)
  and TestAuthPolicyProvider (test auth bypass via X-Test-Role).
* WebServerFixture in Org.Tests now inherits from WebHostFixture;
  BuildApp + ConfigurePipelineAsync hold only Org-specific work.
* Two shared package versions promoted to the root Directory.Packages.props.
* Tests still 30/30 green.
2026-07-06 21:33:57 +01:00
6304ae8a24 PostIt: document the BaseAddress / pathPrefix URL convention
The previous "PostIt: fix blog API double-prefix" commit changed
DefaultPathPrefix from "api/blog" to "blog" without spelling out
the convention. Future-me (or anyone else touching ApiUrl) needs
to know that BaseAddress already terminates in /api/v1/ and that
pathPrefix is relative to that.

* BlogApiClient: add a <para> in the class summary that names the
  convention, points at the matching controller route, and
  cross-references the fix commit.
* postit-oidc.md: add a row in the "Composants partagés" table
  with the same warning, in the architectural-doc voice.
2026-07-06 21:03:10 +01:00
d04a05a5f2 PostIt: fix blog API double-prefix; drop redundant New
BlogApiClient's "api/blog" path combined with the BaseAddress's
"api/v1/" prefix to produce 404s on every call. Drop the redundant
"api/" segment, let Save handle the create case (no selection) and
remove the now-redundant New button + command.
2026-07-06 20:58:58 +01:00
9921d0df5f clean up 2026-07-06 19:19:28 +01:00
fcb93d41d2 code cleanup 2026-07-06 03:31:05 +01:00
694ea7ff23 build 2026-07-06 03:26:57 +01:00
ed9354b0f5 revert 2026-07-06 03:25:19 +01:00
9b15716e31 migration 2026-07-06 03:17:23 +01:00