diff --git a/Directory.Packages.props b/Directory.Packages.props
index e4b09159..e7f71232 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,8 +9,6 @@
-
-
@@ -23,8 +21,6 @@
-
-
\ No newline at end of file
diff --git a/contrib/bruno/Get Posts.bru b/contrib/bruno/Get Posts.bru
deleted file mode 100644
index bafe95b1..00000000
--- a/contrib/bruno/Get Posts.bru
+++ /dev/null
@@ -1,16 +0,0 @@
-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
deleted file mode 100644
index 168811b2..00000000
--- a/contrib/bruno/Untitled.bru
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 2767b01d..00000000
--- a/contrib/bruno/blogs.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index 0fd5430e..00000000
--- a/contrib/bruno/environments/Development.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index fda7173b..00000000
--- a/contrib/bruno/environments/Production.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-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
deleted file mode 100644
index 701cace5..00000000
--- a/contrib/bruno/opencollection.yml
+++ /dev/null
@@ -1,43 +0,0 @@
-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 b189bb27..4afdf585 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -15,7 +15,6 @@ 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 52e6ea97..9d4a0e4a 100644
--- a/doc/architecture/decoupage-organisation.md
+++ b/doc/architecture/decoupage-organisation.md
@@ -31,19 +31,7 @@
└────────────────┘
Clients externes :
- - 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
+ - PostIt : client desktop Avalonia (cf. postit-oidc.md).
```
## Par projet
@@ -56,14 +44,6 @@ Outils et tests :
| `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
deleted file mode 100644
index 77d0a252..00000000
--- a/doc/architecture/postit.md
+++ /dev/null
@@ -1,255 +0,0 @@
-# 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
deleted file mode 100644
index 68fa514e..00000000
--- a/src/PostIt.Tests/BearerScopeTests.cs
+++ /dev/null
@@ -1,288 +0,0 @@
-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
deleted file mode 100644
index 9ec10f89..00000000
--- a/src/PostIt.Tests/BlogApiTestFakes.cs
+++ /dev/null
@@ -1,65 +0,0 @@
-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
new file mode 100644
index 00000000..7a8b579f
--- /dev/null
+++ b/src/PostIt.Tests/LoginPageViewModelTests.cs
@@ -0,0 +1,257 @@
+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
deleted file mode 100644
index c76115d7..00000000
--- a/src/PostIt.Tests/MainPageSaveTests.cs
+++ /dev/null
@@ -1,89 +0,0 @@
-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