diff --git a/Directory.Packages.props b/Directory.Packages.props
index e7f71232..e4b09159 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,6 +9,8 @@
+
+
@@ -21,6 +23,8 @@
+
+
\ No newline at end of file
diff --git a/contrib/bruno/Get Posts.bru b/contrib/bruno/Get Posts.bru
new file mode 100644
index 00000000..bafe95b1
--- /dev/null
+++ b/contrib/bruno/Get Posts.bru
@@ -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.
diff --git a/contrib/bruno/Untitled.bru b/contrib/bruno/Untitled.bru
new file mode 100644
index 00000000..168811b2
--- /dev/null
+++ b/contrib/bruno/Untitled.bru
@@ -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
diff --git a/contrib/bruno/blogs.yml b/contrib/bruno/blogs.yml
new file mode 100644
index 00000000..2767b01d
--- /dev/null
+++ b/contrib/bruno/blogs.yml
@@ -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
diff --git a/contrib/bruno/environments/Development.yml b/contrib/bruno/environments/Development.yml
new file mode 100644
index 00000000..0fd5430e
--- /dev/null
+++ b/contrib/bruno/environments/Development.yml
@@ -0,0 +1,6 @@
+name: Development
+variables:
+ - name: Blogs
+ value: https://localhost:5003
+ - name: Authority
+ value: https://localhost:5001
diff --git a/contrib/bruno/environments/Production.yml b/contrib/bruno/environments/Production.yml
new file mode 100644
index 00000000..fda7173b
--- /dev/null
+++ b/contrib/bruno/environments/Production.yml
@@ -0,0 +1,6 @@
+name: Production
+variables:
+ - name: Authority
+ value: https://yavsc.pschneider.fr
+ - name: Blogs
+ value: https://blogs.pschneider.fr
diff --git a/contrib/bruno/opencollection.yml b/contrib/bruno/opencollection.yml
new file mode 100644
index 00000000..701cace5
--- /dev/null
+++ b/contrib/bruno/opencollection.yml
@@ -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
diff --git a/doc/README.md b/doc/README.md
index 4afdf585..b189bb27 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -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/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.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) |
## Roadmap & design exploration
diff --git a/doc/architecture/decoupage-organisation.md b/doc/architecture/decoupage-organisation.md
index 9d4a0e4a..52e6ea97 100644
--- a/doc/architecture/decoupage-organisation.md
+++ b/doc/architecture/decoupage-organisation.md
@@ -31,7 +31,19 @@
└────────────────┘
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
@@ -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.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.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
diff --git a/doc/architecture/postit.md b/doc/architecture/postit.md
new file mode 100644
index 00000000..77d0a252
--- /dev/null
+++ b/doc/architecture/postit.md
@@ -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(),
+ Settings => _services.GetRequiredService(),
+ HomePageViewModel => _services.GetRequiredService(),
+ SignaturePageViewModel => _services.GetRequiredService(),
+ null => new TextBlock { Text = "No view for " },
+ _ => 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()` 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();
+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.
diff --git a/src/PostIt.Tests/BearerScopeTests.cs b/src/PostIt.Tests/BearerScopeTests.cs
new file mode 100644
index 00000000..68fa514e
--- /dev/null
+++ b/src/PostIt.Tests/BearerScopeTests.cs
@@ -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;
+
+///
+/// Diagnostic coverage for the 401 we're seeing in production when
+/// PostIt talks to Yavsc.Blogs. The hypothesis this file
+/// isolates: "the access token sent on the wire is missing the
+/// blogs scope that Yavsc.Blogs's BlogScope
+/// policy requires". The policy lives in
+/// Yavsc.Blogs/Program.cs as
+/// RequireClaim(JwtClaimTypes.Scope, "blogs").
+///
+///
+/// We do not stand up a real Yavsc.Blogs server, an OIDC stub, or
+/// any network listener. The test fakes a single
+/// that captures the outbound
+/// request, deserialises the bearer JWT, and asserts the
+/// scope claim contains the segment the policy needs. This
+/// pins the client side of the contract so a future regression in
+/// or (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.
+///
+///
+public class BearerScopeTests
+{
+ ///
+ /// Hard-coded blogs scope string. Mirrors the value in
+ /// Yavsc.Blogs/Program.cs's BlogScope policy; if
+ /// the server ever moves to "blog.read" or similar this
+ /// constant should be updated to match.
+ ///
+ 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).
+ 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 -------------------------------------------------------
+
+ ///
+ /// Build an unsigned JWT carrying a single scope claim.
+ /// Mirrors the read-only fallback in
+ /// : base64url-decode
+ /// the middle segment, parse JSON, read the scope string.
+ /// The header and signature are placeholders — nobody in the
+ /// test path verifies the signature.
+ ///
+ 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('/', '_');
+ }
+
+ ///
+ /// Pull the scope 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.
+ ///
+ private static IReadOnlyCollection 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();
+ }
+ var raw = scopeEl.GetString() ?? string.Empty;
+ return raw.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ ///
+ /// Minimal that records the
+ /// first request's Authorization 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.
+ ///
+ private sealed class CapturingHttpHandler : HttpMessageHandler
+ {
+ public string? Authorization { get; private set; }
+ public Uri? RequestUri { get; private set; }
+
+ protected override Task 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);
+ }
+ }
+
+ ///
+ /// Subclass of that routes HTTP
+ /// traffic through a caller-supplied
+ /// . The base ctor wires
+ /// Http as new HttpClient(BearerTokenHandler(...));
+ /// we don't replace that — we override the public call seam
+ ///
+ /// (declared virtual) and talk to our own HttpClient
+ /// from there. The EnsureFreshToken / 401-retry path
+ /// is intentionally not exercised here — that lives in
+ /// YavscApiClientTests; isolating the bearer
+ /// attachment is the whole point of this test.
+ ///
+ 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 CallAsync(
+ 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(stream,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ return Task.FromResult(dto!);
+ }
+ }
+}
diff --git a/src/PostIt.Tests/BlogApiTestFakes.cs b/src/PostIt.Tests/BlogApiTestFakes.cs
new file mode 100644
index 00000000..9ec10f89
--- /dev/null
+++ b/src/PostIt.Tests/BlogApiTestFakes.cs
@@ -0,0 +1,65 @@
+using PostIt.Models;
+using PostIt.Services;
+using PostIt.ViewModels;
+
+namespace PostIt.Tests;
+
+/// 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.
+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();
+}
+
+/// 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.
+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 CallAsync(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))
+ return Task.FromResult((T)(object)new List
+ {
+ new() { Id = 42, Title = "Mon premier billet" }
+ });
+ return Task.FromResult(default(T)!);
+ }
+}
diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs
deleted file mode 100644
index 7a8b579f..00000000
--- a/src/PostIt.Tests/LoginPageViewModelTests.cs
+++ /dev/null
@@ -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 ?? ""}");
- 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 ?? ""}");
- }
-
- [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);
- }
-}
diff --git a/src/PostIt.Tests/MainPageSaveTests.cs b/src/PostIt.Tests/MainPageSaveTests.cs
new file mode 100644
index 00000000..c76115d7
--- /dev/null
+++ b/src/PostIt.Tests/MainPageSaveTests.cs
@@ -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;
+
+///
+/// Headless UI tests for the "Save" flow in .
+/// The pattern is the one SessionStatusBannerTests
+/// established: [AvaloniaFact], a
+/// hosting the page (via a because
+/// MainPage is a ContentPage), then drive the
+/// controls through their public surface and assert on what
+/// saw go on the wire.
+///
+/// The bug we are pinning: the title TextBox is
+/// currently {Binding SelectedPost.Title, Mode=TwoWay}.
+/// When SelectedPost is null (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
+/// if (SelectedPost is null) { new BlogPost { Title = string.Empty, ... } }
+/// 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 Title/Article
+/// buffer that the XAML binds to and the Save command consumes.
+///
+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()
+ .First(t => t.PlaceholderText == "Title");
+ const string typed = "Mon premier billet";
+ titleBox.Text = typed;
+
+ var saveButton = window.GetVisualDescendants()
+ .OfType